Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/tame-cups-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@tailor-platform/app-shell": minor
---

Allow the standalone `Select`, `Combobox`, and `Autocomplete` (including their `.Async` variants) to receive an accessible name via `aria-label`, `aria-labelledby`, and `id`. Previously these props were silently dropped, leaving the combobox with only its current value as an accessible name — a WCAG 4.1.2 issue for filters and toolbars used outside a `Form`. The props are now forwarded to the underlying trigger/input.

```tsx
// Announced as "Direction filter, combobox" instead of just its value
<Select
items={items}
value={value}
onValueChange={setValue}
aria-label="Direction filter"
/>

// Or point at a visible label
<span id="dir-label">From</span>
<Combobox items={items} aria-labelledby="dir-label" />
```
30 changes: 19 additions & 11 deletions docs/components/autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ import { Autocomplete } from "@tailor-platform/app-shell";

### Autocomplete Props

| Prop | Type | Default | Description |
| --------------- | ------------------------- | --------------- | ------------------------------------------------------------------- |
| `items` | `I[]` | - | Suggestion items. May be a flat array or an array of `ItemGroup<T>` |
| `placeholder` | `string` | - | Placeholder text for the input |
| `emptyText` | `string` | `"No results."` | Text shown when no items match |
| `value` | `string` | - | Controlled value (raw input string) |
| `defaultValue` | `string` | - | Initial value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | - | Called when the value changes |
| `mapItem` | `(item: T) => MappedItem` | - | Map each item to its label, key, and optional custom render |
| `className` | `string` | - | Additional CSS classes for the root container |
| `disabled` | `boolean` | `false` | Disables the autocomplete |
| Prop | Type | Default | Description |
| ----------------- | ------------------------- | --------------- | ------------------------------------------------------------------- |
| `items` | `I[]` | - | Suggestion items. May be a flat array or an array of `ItemGroup<T>` |
| `placeholder` | `string` | - | Placeholder text for the input |
| `emptyText` | `string` | `"No results."` | Text shown when no items match |
| `value` | `string` | - | Controlled value (raw input string) |
| `defaultValue` | `string` | - | Initial value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | - | Called when the value changes |
| `mapItem` | `(item: T) => MappedItem` | - | Map each item to its label, key, and optional custom render |
| `className` | `string` | - | Additional CSS classes for the root container |
| `disabled` | `boolean` | `false` | Disables the autocomplete |
| `aria-label` | `string` | - | Accessible name for the input. Use when there is no visible label |
| `aria-labelledby` | `string` | - | ID of the element(s) that label the input |
| `id` | `string` | - | ID applied to the combobox input element |

### MappedItem

Expand Down Expand Up @@ -216,6 +219,11 @@ const suggestions = Autocomplete.useAsync({
- Input is keyboard accessible with arrow key navigation
- Pressing `Escape` closes the suggestion list
- Selecting a suggestion fills the input with the item's label
- When used standalone (no visible `<label>`), give the input an accessible name with `aria-label` or `aria-labelledby` — otherwise screen readers announce only the current value:

```tsx
<Autocomplete items={items} aria-label="City search" />
```

## Related Components

Expand Down
8 changes: 8 additions & 0 deletions docs/components/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import { Combobox } from "@tailor-platform/app-shell";
| `mapItem` | `(item: T) => MappedItem` | - | Map each item to its label, key, and optional custom render |
| `className` | `string` | - | Additional CSS classes for the root container |
| `disabled` | `boolean` | `false` | Disables the combobox |
| `aria-label` | `string` | - | Accessible name for the input. Use when there is no visible label |
| `aria-labelledby` | `string` | - | ID of the element(s) that label the input |
| `id` | `string` | - | ID applied to the combobox input element |
| `onCreateItem` | `(value: string) => T \| false \| Promise<T \| false>` | - | Enable user-created items (requires `mapItem`; `T` must be an object type) |
| `formatCreateLabel` | `(value: string) => string` | `` (v) => `Create "${v}"` `` | Format the label for the "create" option |

Expand Down Expand Up @@ -242,6 +245,11 @@ const countries = Combobox.useAsync({
- Input is keyboard accessible with arrow key navigation
- Pressing `Escape` closes the dropdown
- Multi-select chips have `aria-label` set from the item label
- When used standalone (no visible `<label>`), give the input an accessible name with `aria-label` or `aria-labelledby` — otherwise screen readers announce only the current value:

```tsx
<Combobox items={items} aria-label="Fruit filter" />
```

## Related Components

Expand Down
41 changes: 29 additions & 12 deletions docs/components/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ import { Select } from "@tailor-platform/app-shell";

### Select Props

| Prop | Type | Default | Description |
| --------------- | -------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `items` | `I[]` | - | Items to display. May be a flat array or an array of `ItemGroup<T>` |
| `placeholder` | `string` | - | Placeholder text shown when no value is selected |
| `multiple` | `true \| false \| undefined` | `false` | Enables multi-select mode |
| `value` | `T \| null` (single) or `T[]` (multiple) | - | Controlled value |
| `defaultValue` | `T \| null` (single) or `T[]` (multiple) | - | Initial value (uncontrolled) |
| `onValueChange` | `(value: T \| null) => void` (single) or `(value: T[]) => void` (multiple) | - | Called when the selected value changes |
| `renderValue` | `(value: T \| null \| T[]) => React.ReactNode` | - | Custom render function for the selected value display |
| `mapItem` | `(item: T) => MappedItem` | - | Map each item to its label, key, and optional custom render |
| `className` | `string` | - | Additional CSS classes for the root container |
| `disabled` | `boolean` | `false` | Disables the select |
| Prop | Type | Default | Description |
| ----------------- | -------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `items` | `I[]` | - | Items to display. May be a flat array or an array of `ItemGroup<T>` |
| `placeholder` | `string` | - | Placeholder text shown when no value is selected |
| `multiple` | `true \| false \| undefined` | `false` | Enables multi-select mode |
| `value` | `T \| null` (single) or `T[]` (multiple) | - | Controlled value |
| `defaultValue` | `T \| null` (single) or `T[]` (multiple) | - | Initial value (uncontrolled) |
| `onValueChange` | `(value: T \| null) => void` (single) or `(value: T[]) => void` (multiple) | - | Called when the selected value changes |
| `renderValue` | `(value: T \| null \| T[]) => React.ReactNode` | - | Custom render function for the selected value display |
| `mapItem` | `(item: T) => MappedItem` | - | Map each item to its label, key, and optional custom render |
| `className` | `string` | - | Additional CSS classes for the root container |
| `disabled` | `boolean` | `false` | Disables the select |
| `aria-label` | `string` | - | Accessible name for the trigger. Use when there is no visible label |
| `aria-labelledby` | `string` | - | ID of the element(s) that label the trigger |
| `id` | `string` | - | ID applied to the combobox trigger element |

### MappedItem

Expand Down Expand Up @@ -219,6 +222,20 @@ const fruits = Select.useAsync({
</Select.Parts.Root>;
```

## Accessibility

- The trigger exposes the `combobox` role. When used inside a `Form`/`FormControl` it is labeled automatically.
- When used standalone (e.g. a table toolbar or list filter) there is no visible `<label>`, so give the trigger an accessible name with `aria-label` or `aria-labelledby`. Otherwise screen readers announce only the current value.

```tsx
// Announced as "Direction filter, combobox" instead of just the value
<Select items={items} value={value} onValueChange={setValue} aria-label="Direction filter" />

// Or point at a visible label
<span id="dir-label">From</span>
<Select items={items} aria-labelledby="dir-label" />
```

## Related Components

- [Combobox](./combobox.md) - Searchable combobox with filtering
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/components/autocomplete-standalone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,36 @@ describe("Autocomplete (standalone, grouped)", () => {
expect(screen.getByText("Banana")).toBeDefined();
});
});

// ==========================================================================
// Accessibility — the input can be given an accessible name outside a form
// ==========================================================================

describe("accessible name", () => {
it("forwards aria-label to the input", () => {
render(<Autocomplete items={suggestions} aria-label="City search" />);
expect(screen.getByRole("combobox", { name: "City search" })).toBeDefined();
});

it("forwards aria-labelledby to the input", () => {
render(
<>
<span id="city-label">City</span>
<Autocomplete items={suggestions} aria-labelledby="city-label" />
</>,
);
expect(screen.getByRole("combobox", { name: "City" })).toBeDefined();
});

it("forwards id to the input", () => {
render(<Autocomplete items={suggestions} id="city-input" aria-label="City" />);
expect(screen.getByRole("combobox").getAttribute("id")).toBe("city-input");
});

it("forwards aria-label to the Async input", () => {
const fetcher = vi.fn().mockResolvedValue(suggestions);
render(<Autocomplete.Async fetcher={fetcher} aria-label="Async search" />);
expect(screen.getByRole("combobox", { name: "Async search" })).toBeDefined();
});
});
});
33 changes: 31 additions & 2 deletions packages/core/src/components/autocomplete-standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ interface AutocompletePropsBase<T> {
defaultValue?: string;
/** Called when the value changes */
onValueChange?: (value: string) => void;
/**
* Accessible name for the input. Use when there is no visible label
* (e.g. a table toolbar or list filter) so screen readers announce something
* other than the current value.
*/
"aria-label"?: string;
/**
* ID of the element(s) that label the input. Forwarded to the input — use
* this to point at a visible `<label>`/heading.
*/
"aria-labelledby"?: string;
/** ID applied to the combobox input element. */
id?: string;
}

// --- Autocomplete (static) ---
Expand Down Expand Up @@ -78,6 +91,9 @@ function AutocompleteStandalone<I>(props: AutocompleteStandaloneProps<I>) {
value,
defaultValue,
onValueChange,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
id,
} = props;

const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem;
Expand Down Expand Up @@ -119,7 +135,12 @@ function AutocompleteStandalone<I>(props: AutocompleteStandaloneProps<I>) {
disabled={disabled}
>
<AutocompleteInputGroup>
<AutocompleteInput placeholder={placeholder} />
<AutocompleteInput
placeholder={placeholder}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
id={id}
/>
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
Expand Down Expand Up @@ -155,6 +176,9 @@ function AutocompleteAsyncStandalone<T>(props: AutocompleteAsyncStandaloneProps<
value: controlledValue,
defaultValue,
onValueChange: onValueChangeProp,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
id,
} = props;

const async = useAsync({ fetcher });
Expand All @@ -180,7 +204,12 @@ function AutocompleteAsyncStandalone<T>(props: AutocompleteAsyncStandaloneProps<
disabled={disabled}
>
<AutocompleteInputGroup>
<AutocompleteInput placeholder={placeholder} />
<AutocompleteInput
placeholder={placeholder}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
id={id}
/>
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/components/combobox-standalone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,4 +441,53 @@ describe("Combobox (standalone, grouped)", () => {
expect(screen.getByText("Carrot")).toBeDefined();
});
});

// ==========================================================================
// Accessibility — the input can be given an accessible name outside a form
// ==========================================================================

describe("accessible name", () => {
it("forwards aria-label to the input", () => {
render(<Combobox items={fruits} aria-label="Fruit filter" />);
expect(screen.getByRole("combobox", { name: "Fruit filter" })).toBeDefined();
});

it("forwards aria-labelledby to the input", () => {
render(
<>
<span id="fruit-label">Fruit</span>
<Combobox items={fruits} aria-labelledby="fruit-label" />
</>,
);
expect(screen.getByRole("combobox", { name: "Fruit" })).toBeDefined();
});

it("forwards id to the input", () => {
render(<Combobox items={fruits} id="fruit-combobox" aria-label="Fruit" />);
expect(screen.getByRole("combobox").getAttribute("id")).toBe("fruit-combobox");
});

it("forwards aria-label to the input in multiple mode", () => {
render(<Combobox items={fruits} multiple aria-label="Fruit filter" />);
expect(screen.getByRole("combobox", { name: "Fruit filter" })).toBeDefined();
});

it("forwards aria-label to the input when creatable", () => {
render(
<Combobox
items={creatableItems}
mapItem={mapCreatableItem}
onCreateItem={(value) => ({ label: value })}
aria-label="Tag input"
/>,
);
expect(screen.getByRole("combobox", { name: "Tag input" })).toBeDefined();
});

it("forwards aria-label to the Async input", () => {
const fetcher = vi.fn().mockResolvedValue(fruits);
render(<Combobox.Async fetcher={fetcher} aria-label="Async filter" />);
expect(screen.getByRole("combobox", { name: "Async filter" })).toBeDefined();
});
});
});
Loading
Loading