From c3a4c33129bccf91c0e6bacbbfc37c05e4a55560 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Wed, 10 Jun 2026 21:32:41 +0300 Subject: [PATCH 01/16] fix(Dropdown): keep selected value inside input for searchable single select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The searchable single-select combobox previously forced the input value to null after selection and rendered the selected label as a visual overlay on top of an empty input. Screen readers read the input, not the overlay, so a selected combobox announced as "blank" — failing WCAG 2.1 SC 4.1.2 (Name, Role, Value, Level A). Stop overriding Downshift's default so the selected item's label lives inside the input and is exposed to assistive technologies. Supporting changes keep the component's existing behavior intact: - Seed initialInputValue with the selected label so a defaultValue/value is visible on mount (previously handled by the overlay). - Filter the list only on real user typing (InputChange); ignore the label Downshift writes into the input on selection/blur. - Reset the filter when the menu closes so reopening shows the full list, matching the prior behavior and the documented combobox pattern. The overlay in SingleSelectTrigger now naturally renders only for non-searchable single select (where inputValue stays null), so that path is unaffected. Multi-select uses a separate hook and is untouched. Co-Authored-By: Claude Opus 4.8 --- .../Dropdown/__tests__/Dropdown.test.tsx | 26 +++++++++---------- .../Dropdown/hooks/useDropdownCombobox.ts | 24 ++++++++++++----- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx index 6bf45594e0..81d75b91f1 100644 --- a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx +++ b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -249,19 +249,20 @@ describe("DropdownNew", () => { } }); - it("should show faded selected item when focused", () => { - const { getByPlaceholderText, container, getByText } = renderDropdown({ + it("should keep the selected value inside the input for searchable single select", () => { + const { getByPlaceholderText, getByText } = renderDropdown({ placeholder: "Select an option" }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); - fireEvent.focus(input); + // The selection lives inside the input (exposed to assistive technologies), not in a visual overlay. + expect(input).toHaveValue("Option 1"); - const selectedValue = container.querySelector(".selectedItem"); - expect(selectedValue).toHaveClass("faded"); + fireEvent.focus(input); + expect(input).toHaveValue("Option 1"); }); it("should hide selected value when typing in the input", () => { @@ -275,7 +276,7 @@ describe("DropdownNew", () => { expect(queryByText("Option 1")).not.toBeInTheDocument(); }); - it("should not display indent startElement in selected value", () => { + it("should not display indent startElement in selected value (non-searchable overlay)", () => { const optionsWithIndent: DropdownListGroup>>[] = [ { label: "Group 1", @@ -290,14 +291,13 @@ describe("DropdownNew", () => { } ]; - const { getByPlaceholderText, getByText, container } = renderDropdown({ - options: optionsWithIndent + // Non-searchable single select displays the selection via the overlay, where indent must be stripped. + const { container } = renderDropdown({ + options: optionsWithIndent, + searchable: false, + value: { label: "Option 1", value: "opt1", index: 0, startElement: { type: "indent" } } as any }); - const input = getByPlaceholderText("Select an option"); - fireEvent.click(input); - fireEvent.click(getByText("Option 1")); - const selectedValue = container.querySelector(".selectedItem"); expect(selectedValue).toBeInTheDocument(); diff --git a/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts b/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts index ea54fee0ff..f9418a1509 100644 --- a/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts +++ b/packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts @@ -57,18 +57,29 @@ function useDropdownCombobox>>( itemToString: item => item?.label ?? "", itemToKey: item => (item?.value !== undefined ? String(item.value) : ""), isItemDisabled: item => Boolean(item.disabled), - initialInputValue: inputValueProp || "", + // Seed the input with the selected item's label so a defaultValue/value is visible (and exposed to + // assistive technologies) on mount, now that the selection lives inside the input rather than in an overlay. + initialInputValue: inputValueProp || selectedItem?.label || "", selectedItem: selectedItem, isOpen: isMenuOpen, initialIsOpen: autoFocus, id, onIsOpenChange: ({ isOpen }) => { + // Reset the text filter when the menu closes so reopening always shows the full option list, + // even though the input keeps displaying the selected item's label. + if (!isOpen) { + filterOptions(""); + } isOpen ? onMenuClose?.() : onMenuOpen?.(); }, onInputValueChange: useCallback( - ({ inputValue }) => { - filterOptions(inputValue || ""); + ({ inputValue, type }) => { + // Only filter on actual user typing. Downshift also writes the selected item's label into the + // input on selection/blur — those changes must not filter the list. + if (type === useCombobox.stateChangeTypes.InputChange) { + filterOptions(inputValue || ""); + } onInputChange?.(inputValue); }, [onInputChange, filterOptions] @@ -105,10 +116,9 @@ function useDropdownCombobox>>( switch (actionAndChanges.type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: - return { ...actionAndChanges.changes, inputValue: null, isOpen: !closeMenuOnSelect }; - case useCombobox.stateChangeTypes.InputBlur: - case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: - return { ...actionAndChanges.changes, inputValue: null }; + // Keep Downshift's default inputValue (the selected item's label) so the selection lives inside + // the input and is exposed to assistive technologies. Only override the open state. + return { ...actionAndChanges.changes, isOpen: !closeMenuOnSelect }; default: return actionAndChanges.changes; From 6edf9bae5dc49bb79d5d0e3c4cc659a1f40c9790 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Wed, 10 Jun 2026 21:54:37 +0300 Subject: [PATCH 02/16] docs(Dropdown): add dedicated Searchable single select storybook page Comprehensive story page covering all searchable single-select variants: overview, sizes, states, default/controlled value, icons & avatars, groups, tooltips, clearable/max-height, and custom filter / empty message. The default-value story demonstrates the selected value living inside the input. Co-Authored-By: Claude Opus 4.8 --- ...DropdownSearchableSingleSelect.stories.tsx | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx new file mode 100644 index 0000000000..0290183df7 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx @@ -0,0 +1,456 @@ +import React, { useMemo, useState } from "react"; +import { type Meta, type StoryObj } from "@storybook/react"; +import { createStoryMetaSettingsDecorator } from "../../../utils/createStoryMetaSettingsDecorator"; +import person1 from "../Avatar/assets/person1.png"; +import person2 from "../Avatar/assets/person2.png"; +import person3 from "../Avatar/assets/person3.png"; +import { Attach, Email } from "@vibe/icons"; +import { Dropdown, type BaseDropdownProps, type DropdownOption, Flex, Text } from "@vibe/core"; + +type Story = StoryObj; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: Dropdown, + actionPropsArray: [ + "onMenuOpen", + "onMenuClose", + "onFocus", + "onBlur", + "onChange", + "openMenuOnFocus", + "onOptionSelect", + "onClear", + "onInputChange", + "onKeyDown" + ] +}); + +const meta: Meta = { + title: "Components/Dropdown/Searchable single select", + component: Dropdown, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators +}; + +export default meta; + +const basicOptions = [ + { value: "marketing", label: "Marketing" }, + { value: "design", label: "Design" }, + { value: "engineering", label: "Engineering" }, + { value: "product", label: "Product" }, + { value: "sales", label: "Sales" } +]; + +const dropdownTemplate = (props: BaseDropdownProps) => { + const options = useMemo(() => basicOptions, []); + + return ( +
+ +
+ ); +}; + +export const Overview: Story = { + render: dropdownTemplate.bind({}), + args: { + id: "searchable-single-overview", + "aria-label": "Searchable single select", + placeholder: "Search a team", + clearAriaLabel: "Clear" + }, + parameters: { + docs: { + liveEdit: { + isEnabled: false + } + } + } +}; + +export const Sizes: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( + +
+ +
+
+ +
+
+ +
+
+ ); + } +}; + +export const States: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( + + +
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+
+ ); + } +}; + +export const WithDefaultValue: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + return ( +
+ + The selected value lives inside the input, so it is exposed to screen readers on mount. + + +
+ ); + } +}; + +export const Controlled: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + const [value, setValue] = useState(options[1]); + + return ( + + Selected: {value?.label ?? "none"} + setValue(option)} + onClear={() => setValue(null)} + clearAriaLabel="Clear" + /> + + ); + } +}; + +export const WithIconsAndAvatars: Story = { + render: () => { + const iconOptions = useMemo( + () => [ + { value: "email", label: "Email", startElement: { type: "icon", value: Email } }, + { value: "attach", label: "Attach", startElement: { type: "icon", value: Attach } } + ], + [] + ); + const avatarOptions = useMemo( + () => [ + { value: "julia", label: "Julia Martinez", startElement: { type: "avatar", value: person1 } }, + { value: "sophia", label: "Sophia Johnson", startElement: { type: "avatar", value: person2 } }, + { value: "marco", label: "Marco DiAngelo", startElement: { type: "avatar", value: person3 } } + ], + [] + ); + + return ( + +
+ +
+
+ +
+
+ ); + }, + parameters: { + docs: { + liveEdit: { + scope: { person1, person2, person3 } + } + } + } +}; + +export const WithGroups: Story = { + render: () => { + const groupedOptions = useMemo( + () => [ + { + label: "Engineering", + options: [ + { value: "frontend", label: "Frontend" }, + { value: "backend", label: "Backend" }, + { value: "infra", label: "Infrastructure" } + ] + }, + { + label: "Business", + options: [ + { value: "marketing", label: "Marketing" }, + { value: "sales", label: "Sales" } + ] + } + ], + [] + ); + + return ( + + + Grouped by category +
+ +
+
+ + Sticky group titles +
+ +
+
+ + Group by divider +
+ +
+
+
+ ); + } +}; + +export const WithTooltips: Story = { + render: () => { + const options = useMemo( + () => [ + { + value: "marketing", + label: "Marketing", + tooltipProps: { content: "Campaigns, content and brand." } + }, + { + value: "design", + label: "Design", + tooltipProps: { content: "Product and brand design." } + }, + { value: "engineering", label: "Engineering" } + ], + [] + ); + + return ( +
+ +
+ ); + } +}; + +export const ClearableAndMaxHeight: Story = { + render: () => { + const options = useMemo( + () => + Array.from({ length: 30 }, (_, index) => ({ + value: `option-${index + 1}`, + label: `Option ${index + 1}` + })), + [] + ); + + return ( + +
+ +
+
+ +
+
+ ); + } +}; + +export const CustomFilterAndNoOptions: Story = { + render: () => { + const options = useMemo(() => basicOptions, []); + + // Match only from the start of the label instead of the default substring match. + const startsWithFilter = (option: DropdownOption, inputValue: string) => + option.label.toLowerCase().startsWith(inputValue.toLowerCase()); + + return ( + + + Custom "starts with" filter +
+ +
+
+ + Custom empty message +
+ +
+
+
+ ); + } +}; From 3417b827ddf3d4cb6244e204ddc03d7b87466fef Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Wed, 10 Jun 2026 22:34:33 +0300 Subject: [PATCH 03/16] docs(Dropdown): add searchable single select accessibility reference Single-page accessibility reference covering the selected-value-in-input behavior, WCAG criteria, keyboard interaction, screen reader output, and the accessibility-relevant props (naming, state, feedback). Excludes layout props like size that do not affect accessibility. Co-Authored-By: Claude Opus 4.8 --- .../searchable-single-select-accessibility.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md diff --git a/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md b/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md new file mode 100644 index 0000000000..22839f28ea --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md @@ -0,0 +1,115 @@ +# Searchable Single Select — Accessibility + +A single reference for the accessibility behavior and the props that affect it for the **searchable single select** Dropdown (`searchable` + single value). + +--- + +## The core behavior: the selected value lives inside the input + +When an option is selected, its label is set as the **value of the ``**. It is not painted as a separate visual layer on top of an empty field. + +This matters because assistive technologies read the input's value, not whatever is layered around it. With the value in the input: + +- Tabbing to a combobox that already has a selection announces *"Team, Engineering, combobox"* — not *"Team, combobox, blank"*. +- The current value is exposed on mount for `defaultValue` / controlled `value`, not only after interaction. + +> Previously the input was force-cleared after selection and the label was shown as an overlay. Screen readers announced the field as blank, which failed WCAG 4.1.2. Keeping the value in the input is the fix. + +### What happens on open / reopen / type + +| Action | Behavior | +|---|---| +| Select an option | Label is placed in the input; menu closes (default) | +| Reopen with a selection | The **full** option list is shown; the selected option is marked `aria-selected="true"` and highlighted (`aria-activedescendant`) | +| Start typing | The list filters by the typed text | +| Clear text | The full list returns | + +Showing the full list on reopen (rather than filtering down to the single selected label) is intentional: it lets users browse and change their choice, and avoids a screen reader announcing *"1 of 1"* when more options exist. + +--- + +## WCAG criteria addressed + +| Criterion | Level | How it applies | +|---|---|---| +| **4.1.2 Name, Role, Value** | A | The combobox role, its accessible name, and its **current value** are all programmatically determinable. This is the criterion the selected-value-in-input behavior satisfies. | +| **1.3.1 Info and Relationships** | A | The label, helper text, and error state are programmatically associated with the input (not just visually adjacent). | +| **3.3.2 Labels or Instructions** | A | A persistent `label` (and `helperText`) tells the user what the field is for. | +| **3.3.1 Error Identification** | A | `error` + a descriptive `helperText` identify and describe the problem in text, not by color alone. | +| **2.1.1 Keyboard** | A | Fully operable with the keyboard (see below). | +| **4.1.3 Status Messages** | AA | Results / "no options" feedback is exposed to assistive tech without moving focus. | + +--- + +## Keyboard interaction + +| Key | Result | +|---|---| +| `Tab` | Move focus to the combobox input | +| `↓` / `↑` | Open the menu / move the highlight between options | +| `Enter` | Select the highlighted option | +| `Esc` | Close the menu (input reverts to the selected value) | +| Type text | Filter the option list | +| `Tab` (on clear button) | Move to the clear button, `Enter`/`Space` clears the selection | + +--- + +## Screen reader output + +- The input has `role="combobox"`, `aria-expanded`, and its **value reflects the selection**. +- Each option has `aria-selected="true | false"`; the active option is referenced by `aria-activedescendant`. +- On open after a selection, the highlight lands on the selected option, so it is announced as *"…, selected"*. +- An empty result set announces the `noOptionsMessage` text. + +--- + +## Accessibility-relevant props + +Layout props such as `size` are omitted — they do not affect accessibility. The props below do. + +### Naming — give the field an accessible name (required for 4.1.2) + +| Prop | Purpose | Notes | +|---|---|---| +| `label` | Visible text label, programmatically associated with the input (`aria-labelledby`). | **Preferred.** Visible to everyone and announced by screen readers. | +| `aria-label` | Accessible name when there is **no** visible `label`. | Use only when a visible label is not possible. | +| `inputAriaLabel` | Accessible name applied specifically to the inner search input. | Useful when the input needs a name distinct from the field label. | +| `menuAriaLabel` | Accessible name for the option list (`listbox`). | Helps orient users when the menu opens. | +| `clearAriaLabel` | Accessible name for the clear (✕) button. | **Important.** Without it the clear button is an icon-only control with no name — a 4.1.2 failure. Always set it when `clearable`. | + +### State — communicate status to assistive tech + +| Prop | Purpose | Notes | +|---|---|---| +| `required` | Marks the field required (`aria-required`) and shows the visual indicator. | Pair with form-level validation. | +| `error` | Puts the field in an error state (`aria-invalid`). | **Always pair with `helperText`** describing the error — color alone is not sufficient (1.4.1). | +| `helperText` | Descriptive text associated via `aria-describedby`. | Use for instructions and for the error message text. | +| `disabled` | Non-interactive, not focusable; communicated to AT. | Removes the field from the tab order. | +| `readOnly` | Value is shown and announced but not editable. | Prefer over `disabled` when the user still needs to read the value. | + +### Feedback + +| Prop | Purpose | Notes | +|---|---|---| +| `noOptionsMessage` | Text announced when a search yields no results. | Give a meaningful message (e.g. "No teams found") rather than leaving it empty. | + +### Placeholder is not a label + +| Prop | Purpose | Notes | +|---|---|---| +| `placeholder` | Hint text shown when the field is empty. | **Not a substitute for `label`.** It disappears once a value is present and is not a reliable accessible name. Always provide `label` or `aria-label` in addition. | + +--- + +## Do / Don't + +**Do** +- Provide a `label` (or `aria-label`) on every dropdown. +- Set `clearAriaLabel` whenever the field is clearable. +- Pair `error` with a descriptive `helperText`. +- Provide a meaningful `noOptionsMessage`. + +**Don't** +- Rely on `placeholder` as the field's name. +- Convey the error state with color/`error` only, with no text. +- Assume the visual selection is enough — the value must live in the input (this is handled by the component). From 11b7f865b70c36484ae97e3f16cfe22a6dd1f13b Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Wed, 10 Jun 2026 22:40:40 +0300 Subject: [PATCH 04/16] docs(Dropdown): make a11y reference a Storybook page, trim to essentials Convert the plain .md (which Storybook does not pick up) into an .mdx docs page for the Searchable single select group, with the live examples embedded. Trim to accessibility essentials: core selected-value-in-input behavior, WCAG 4.1.2, and the accessibility-relevant props. Dropped the generic keyboard and screen-reader sections and the broader WCAG list. Co-Authored-By: Claude Opus 4.8 --- .../DropdownSearchableSingleSelect.mdx | 93 ++++++++++++++ .../searchable-single-select-accessibility.md | 115 ------------------ 2 files changed, 93 insertions(+), 115 deletions(-) create mode 100644 packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx delete mode 100644 packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx new file mode 100644 index 0000000000..3435deea87 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx @@ -0,0 +1,93 @@ +import { Meta, Canvas } from "@storybook/blocks"; +import * as DropdownSearchableSingleSelectStories from "./DropdownSearchableSingleSelect.stories"; + + + +# Searchable single select — Accessibility + +A single reference for the accessibility behavior and the props that affect it for the **searchable single select** Dropdown (`searchable` with a single value). + +### Import + +```js +import { Dropdown } from "@vibe/core"; +``` + + + +## The core behavior: the selected value lives inside the input + +When an option is selected, its label is set as the **value of the ``**. It is not painted as a separate visual layer on top of an empty field. + +This matters because assistive technologies read the input's value, not whatever is layered around it. With the value in the input: + +- Tabbing to a combobox that already has a selection announces _"Team, Engineering, combobox"_ — not _"Team, combobox, blank"_. +- The current value is exposed on mount for `defaultValue` / controlled `value`, not only after interaction. + + + +> Previously the input was force-cleared after selection and the label was shown as an overlay. Screen readers announced the field as blank, which failed WCAG 4.1.2. Keeping the value in the input is the fix. + +### What happens on open / reopen / type + +| Action | Behavior | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Select an option | Label is placed in the input; menu closes (default) | +| Reopen with a selection | The **full** option list is shown; the selected option is marked `aria-selected="true"` and highlighted | +| Start typing | The list filters by the typed text | +| Clear text | The full list returns | + +Showing the full list on reopen (rather than filtering down to the single selected label) is intentional: it lets users browse and change their choice, and avoids a screen reader announcing _"1 of 1"_ when more options exist. + +> **WCAG 4.1.2 Name, Role, Value (Level A)** requires the **current value** of a form control to be programmatically determinable. Keeping the selected label in the input — rather than in a visual overlay — is what satisfies this. + +## Accessibility-relevant props + +Layout props such as `size` are omitted — they do not affect accessibility. The props below do. + +### Naming — give the field an accessible name (required for 4.1.2) + +| Prop | Purpose | Notes | +| ---------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `label` | Visible text label, programmatically associated with the input (`aria-labelledby`). | **Preferred.** Visible to everyone and announced by screen readers. | +| `aria-label` | Accessible name when there is **no** visible `label`. | Use only when a visible label is not possible. | +| `inputAriaLabel` | Accessible name applied specifically to the inner search input. | Useful when the input needs a name distinct from the field label. | +| `menuAriaLabel` | Accessible name for the option list (`listbox`). | Helps orient users when the menu opens. | +| `clearAriaLabel` | Accessible name for the clear (✕) button. | **Important.** Without it the clear button is an icon-only control with no name — a 4.1.2 failure. Always set it when `clearable`. | + +### State — communicate status to assistive tech + +| Prop | Purpose | Notes | +| ----------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `required` | Marks the field required (`aria-required`) and shows the indicator. | Pair with form-level validation. | +| `error` | Puts the field in an error state (`aria-invalid`). | **Always pair with `helperText`** describing the error — color alone is not sufficient (1.4.1). | +| `helperText`| Descriptive text associated via `aria-describedby`. | Use for instructions and for the error message text. | +| `disabled` | Non-interactive, not focusable; communicated to AT. | Removes the field from the tab order. | +| `readOnly` | Value is shown and announced but not editable. | Prefer over `disabled` when the user still needs to read the value. | + +### Feedback + +| Prop | Purpose | Notes | +| ------------------ | ------------------------------------------------ | ------------------------------------------------------------------ | +| `noOptionsMessage` | Text announced when a search yields no results. | Give a meaningful message (e.g. "No teams found"), not empty. | + +### Placeholder is not a label + +| Prop | Purpose | Notes | +| ------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `placeholder` | Hint text shown when the field is empty. | **Not a substitute for `label`.** It disappears once a value is present and is not a reliable accessible name. Always provide `label` or `aria-label` in addition. | + +## Do / Don't + +**Do** + +- Provide a `label` (or `aria-label`) on every dropdown. +- Set `clearAriaLabel` whenever the field is clearable. +- Pair `error` with a descriptive `helperText`. +- Provide a meaningful `noOptionsMessage`. + +**Don't** + +- Rely on `placeholder` as the field's name. +- Convey the error state with color / `error` only, with no text. +- Assume the visual selection is enough — the value must live in the input (this is handled by the component). diff --git a/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md b/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md deleted file mode 100644 index 22839f28ea..0000000000 --- a/packages/docs/src/pages/components/Dropdown/searchable-single-select-accessibility.md +++ /dev/null @@ -1,115 +0,0 @@ -# Searchable Single Select — Accessibility - -A single reference for the accessibility behavior and the props that affect it for the **searchable single select** Dropdown (`searchable` + single value). - ---- - -## The core behavior: the selected value lives inside the input - -When an option is selected, its label is set as the **value of the ``**. It is not painted as a separate visual layer on top of an empty field. - -This matters because assistive technologies read the input's value, not whatever is layered around it. With the value in the input: - -- Tabbing to a combobox that already has a selection announces *"Team, Engineering, combobox"* — not *"Team, combobox, blank"*. -- The current value is exposed on mount for `defaultValue` / controlled `value`, not only after interaction. - -> Previously the input was force-cleared after selection and the label was shown as an overlay. Screen readers announced the field as blank, which failed WCAG 4.1.2. Keeping the value in the input is the fix. - -### What happens on open / reopen / type - -| Action | Behavior | -|---|---| -| Select an option | Label is placed in the input; menu closes (default) | -| Reopen with a selection | The **full** option list is shown; the selected option is marked `aria-selected="true"` and highlighted (`aria-activedescendant`) | -| Start typing | The list filters by the typed text | -| Clear text | The full list returns | - -Showing the full list on reopen (rather than filtering down to the single selected label) is intentional: it lets users browse and change their choice, and avoids a screen reader announcing *"1 of 1"* when more options exist. - ---- - -## WCAG criteria addressed - -| Criterion | Level | How it applies | -|---|---|---| -| **4.1.2 Name, Role, Value** | A | The combobox role, its accessible name, and its **current value** are all programmatically determinable. This is the criterion the selected-value-in-input behavior satisfies. | -| **1.3.1 Info and Relationships** | A | The label, helper text, and error state are programmatically associated with the input (not just visually adjacent). | -| **3.3.2 Labels or Instructions** | A | A persistent `label` (and `helperText`) tells the user what the field is for. | -| **3.3.1 Error Identification** | A | `error` + a descriptive `helperText` identify and describe the problem in text, not by color alone. | -| **2.1.1 Keyboard** | A | Fully operable with the keyboard (see below). | -| **4.1.3 Status Messages** | AA | Results / "no options" feedback is exposed to assistive tech without moving focus. | - ---- - -## Keyboard interaction - -| Key | Result | -|---|---| -| `Tab` | Move focus to the combobox input | -| `↓` / `↑` | Open the menu / move the highlight between options | -| `Enter` | Select the highlighted option | -| `Esc` | Close the menu (input reverts to the selected value) | -| Type text | Filter the option list | -| `Tab` (on clear button) | Move to the clear button, `Enter`/`Space` clears the selection | - ---- - -## Screen reader output - -- The input has `role="combobox"`, `aria-expanded`, and its **value reflects the selection**. -- Each option has `aria-selected="true | false"`; the active option is referenced by `aria-activedescendant`. -- On open after a selection, the highlight lands on the selected option, so it is announced as *"…, selected"*. -- An empty result set announces the `noOptionsMessage` text. - ---- - -## Accessibility-relevant props - -Layout props such as `size` are omitted — they do not affect accessibility. The props below do. - -### Naming — give the field an accessible name (required for 4.1.2) - -| Prop | Purpose | Notes | -|---|---|---| -| `label` | Visible text label, programmatically associated with the input (`aria-labelledby`). | **Preferred.** Visible to everyone and announced by screen readers. | -| `aria-label` | Accessible name when there is **no** visible `label`. | Use only when a visible label is not possible. | -| `inputAriaLabel` | Accessible name applied specifically to the inner search input. | Useful when the input needs a name distinct from the field label. | -| `menuAriaLabel` | Accessible name for the option list (`listbox`). | Helps orient users when the menu opens. | -| `clearAriaLabel` | Accessible name for the clear (✕) button. | **Important.** Without it the clear button is an icon-only control with no name — a 4.1.2 failure. Always set it when `clearable`. | - -### State — communicate status to assistive tech - -| Prop | Purpose | Notes | -|---|---|---| -| `required` | Marks the field required (`aria-required`) and shows the visual indicator. | Pair with form-level validation. | -| `error` | Puts the field in an error state (`aria-invalid`). | **Always pair with `helperText`** describing the error — color alone is not sufficient (1.4.1). | -| `helperText` | Descriptive text associated via `aria-describedby`. | Use for instructions and for the error message text. | -| `disabled` | Non-interactive, not focusable; communicated to AT. | Removes the field from the tab order. | -| `readOnly` | Value is shown and announced but not editable. | Prefer over `disabled` when the user still needs to read the value. | - -### Feedback - -| Prop | Purpose | Notes | -|---|---|---| -| `noOptionsMessage` | Text announced when a search yields no results. | Give a meaningful message (e.g. "No teams found") rather than leaving it empty. | - -### Placeholder is not a label - -| Prop | Purpose | Notes | -|---|---|---| -| `placeholder` | Hint text shown when the field is empty. | **Not a substitute for `label`.** It disappears once a value is present and is not a reliable accessible name. Always provide `label` or `aria-label` in addition. | - ---- - -## Do / Don't - -**Do** -- Provide a `label` (or `aria-label`) on every dropdown. -- Set `clearAriaLabel` whenever the field is clearable. -- Pair `error` with a descriptive `helperText`. -- Provide a meaningful `noOptionsMessage`. - -**Don't** -- Rely on `placeholder` as the field's name. -- Convey the error state with color/`error` only, with no text. -- Assume the visual selection is enough — the value must live in the input (this is handled by the component). From 127c22d33ca35266d6d8e5983a9430a36e88543e Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Thu, 11 Jun 2026 13:05:07 +0300 Subject: [PATCH 05/16] fix(Dropdown): remove leftover selected-value overlay for searchable single select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .selectedItem overlay was previously hidden for searchable mode only via the `!inputValue` guard, so it reappeared and coexisted with the input value whenever the input text was cleared while a selection remained. Gate the overlay on `!searchable` instead — for searchable single select the value lives inside the input, so the overlay must never render. Removes the now-dead `faded`/`hasSelected` classes and their styles. Co-Authored-By: Claude Opus 4.8 --- .../components/Trigger/DropdownInput.tsx | 1 - .../components/Trigger/SingleSelectTrigger.tsx | 16 ++++------------ .../components/Trigger/Trigger.module.scss | 10 ---------- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx b/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx index 0ecb0718fa..8647222adb 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx @@ -46,7 +46,6 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium autoFocus={autoFocus} size={inputSize || size} className={cx(styles.inputWrapper, { - [styles.hasSelected]: !multi && selectedItem && !inputValue, [styles.small]: inputSize === "small", [styles.multi]: multi && hasSelection, [styles.multiSelected]: multi && hasSelection && inputSize === "small", diff --git a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx index 87941c5b22..1047ab0ca9 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/SingleSelectTrigger.tsx @@ -10,12 +10,10 @@ import { getStyle } from "@vibe/shared"; const SingleSelectTrigger = () => { const { - inputValue, selectedItem, searchable, size, valueRenderer, - isFocused, getToggleButtonProps, disabled, readOnly, @@ -42,16 +40,10 @@ const SingleSelectTrigger = () => { > - {!inputValue && selectedItem && ( -
+ {/* Non-searchable single select shows the selection via this overlay. In searchable mode the + selected value lives inside the input itself, so the overlay must not render. */} + {!searchable && selectedItem && ( +
Date: Thu, 11 Jun 2026 13:16:38 +0300 Subject: [PATCH 06/16] docs(Dropdown): document selected-value behavior change and add trade-off examples Add a clear "What changed" before/after section to the searchable single select accessibility page, and stories that demonstrate the text-only collapsed selected value: preselected start elements (icons/avatars), end elements (trailing icon, suffix/hint), and a custom valueRenderer that is not applied to the searchable selected display. Remove the Do/Don't section. Co-Authored-By: Claude Opus 4.8 --- .../DropdownSearchableSingleSelect.mdx | 47 +++--- ...DropdownSearchableSingleSelect.stories.tsx | 138 ++++++++++++++++-- 2 files changed, 153 insertions(+), 32 deletions(-) diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx index 3435deea87..8fbda9a1c2 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx @@ -15,6 +15,22 @@ import { Dropdown } from "@vibe/core"; +## What changed + +Previously, selecting an option in a searchable single select **cleared the input** and rendered the selected label as a **visual overlay** on top of the empty field. Because screen readers read the input's value — not the overlay — a field with a selection was announced as blank. + +The behavior was changed so the selected option's label is now set as the **value of the input itself**, and the overlay was removed. + +| Aspect | Before | After | +| ----------------------------------- | --------------------------------------- | --------------------------- | +| Input value after selecting | Empty | The selected label | +| How the selection is shown | Visual overlay over an empty input | Inside the input | +| Screen reader on focus | _"…, blank"_ | _"…, {selected label}"_ | +| `defaultValue` / `value` on mount | Shown via the overlay | Shown in the input | +| Collapsed selected value | Rich (icon / avatar / `valueRenderer`) | Text-only | + +This is scoped to **searchable single select**. Non-searchable single select and multi-select are unchanged. + ## The core behavior: the selected value lives inside the input When an option is selected, its label is set as the **value of the ``**. It is not painted as a separate visual layer on top of an empty field. @@ -26,8 +42,6 @@ This matters because assistive technologies read the input's value, not whatever -> Previously the input was force-cleared after selection and the label was shown as an overlay. Screen readers announced the field as blank, which failed WCAG 4.1.2. Keeping the value in the input is the fix. - ### What happens on open / reopen / type | Action | Behavior | @@ -41,6 +55,20 @@ Showing the full list on reopen (rather than filtering down to the single select > **WCAG 4.1.2 Name, Role, Value (Level A)** requires the **current value** of a form control to be programmatically determinable. Keeping the selected label in the input — rather than in a visual overlay — is what satisfies this. +## Selected value display — trade-off + +Because the selected value now lives inside a native input (which can only hold a string), the **collapsed selected value is text-only**. Anything an option carries beyond its label is shown **in the option list** but not in the selected display: + +- `startElement` — leading icon / avatar / indent +- `endElement` — trailing icon / suffix / hint text +- a custom `valueRenderer` (applies only to **non-searchable** single select) + + + + + + + ## Accessibility-relevant props Layout props such as `size` are omitted — they do not affect accessibility. The props below do. @@ -76,18 +104,3 @@ Layout props such as `size` are omitted — they do not affect accessibility. Th | Prop | Purpose | Notes | | ------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `placeholder` | Hint text shown when the field is empty. | **Not a substitute for `label`.** It disappears once a value is present and is not a reliable accessible name. Always provide `label` or `aria-label` in addition. | - -## Do / Don't - -**Do** - -- Provide a `label` (or `aria-label`) on every dropdown. -- Set `clearAriaLabel` whenever the field is clearable. -- Pair `error` with a descriptive `helperText`. -- Provide a meaningful `noOptionsMessage`. - -**Don't** - -- Rely on `placeholder` as the field's name. -- Convey the error state with color / `error` only, with no text. -- Assume the visual selection is enough — the value must live in the input (this is handled by the component). diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx index 0290183df7..5d2129305a 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.stories.tsx @@ -235,23 +235,131 @@ export const WithIconsAndAvatars: Story = { ); return ( - -
- -
+ + + Each option is preselected. The icon / avatar shows in the option list, but the collapsed selected value + inside the input is text-only — a native input can only hold a string. + + +
+ +
+
+ +
+
+
+ ); + }, + parameters: { + docs: { + liveEdit: { + scope: { person1, person2, person3 } + } + } + } +}; + +export const WithEndElements: Story = { + render: () => { + const endIconOptions = useMemo( + () => [ + { value: "email", label: "Email", endElement: { type: "icon", value: Email } }, + { value: "attach", label: "Attach", endElement: { type: "icon", value: Attach } } + ], + [] + ); + const suffixOptions = useMemo( + () => [ + { value: "copy", label: "Copy", endElement: { type: "suffix", value: "⌘C" } }, + { value: "paste", label: "Paste", endElement: { type: "suffix", value: "⌘V" } } + ], + [] + ); + + return ( + + + Trailing icons and suffix / hint text appear in the option list, but are dropped from the collapsed selected + value (text-only). + + +
+ +
+
+ +
+
+
+ ); + } +}; + +export const WithValueRenderer: Story = { + render: () => { + const options = useMemo( + () => [ + { value: "julia", label: "Julia Martinez", startElement: { type: "avatar", value: person1 } }, + { value: "sophia", label: "Sophia Johnson", startElement: { type: "avatar", value: person2 } } + ], + [] + ); + + const valueRenderer = (option: DropdownOption) => ( + + + Custom: {option.label} + + ); + + return ( + + + A custom valueRenderer is provided and the value is preselected. For searchable single select it + is not applied to the collapsed selected value — the input shows the plain label text only. + (valueRenderer still applies to non-searchable single select.) +
@@ -262,7 +370,7 @@ export const WithIconsAndAvatars: Story = { parameters: { docs: { liveEdit: { - scope: { person1, person2, person3 } + scope: { person1, person2 } } } } From 8b0f58272bae1e728b38126eaa346a8ae7fc516c Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Thu, 11 Jun 2026 13:49:07 +0300 Subject: [PATCH 07/16] fix(docs): escape MDX expression in searchable single select page MDX parses {curly braces} as JS expressions; "{selected label}" is not valid JS and broke the Storybook/Chromatic preview build with an acorn parse error. Replace it with plain text. Co-Authored-By: Claude Opus 4.8 --- .../components/Dropdown/DropdownSearchableSingleSelect.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx index 8fbda9a1c2..fdb76efdc6 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx +++ b/packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx @@ -25,7 +25,7 @@ The behavior was changed so the selected option's label is now set as the **valu | ----------------------------------- | --------------------------------------- | --------------------------- | | Input value after selecting | Empty | The selected label | | How the selection is shown | Visual overlay over an empty input | Inside the input | -| Screen reader on focus | _"…, blank"_ | _"…, {selected label}"_ | +| Screen reader on focus | _"…, blank"_ | _"…, the selected label"_ | | `defaultValue` / `value` on mount | Shown via the overlay | Shown in the input | | Collapsed selected value | Rich (icon / avatar / `valueRenderer`) | Text-only | From ec820229f36fb8113650987e7aee3e66a135916d Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:01:21 +0300 Subject: [PATCH 08/16] feat(Dropdown): add textInput and interactiveChips multi-select modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textInput: selected items are shown as a comma-separated summary in the input (WCAG 4.1.2 — exposes value to assistive technologies on focus). interactiveChips: chips stay visible alongside the input; keyboard nav via getSelectedItemProps — ArrowLeft/Right moves between chips, Backspace/Delete removes the focused chip, ArrowLeft from an empty input navigates to the last chip or the +N overflow badge. Chips overflow uses the existing useItemsOverflow hook for the +N count badge. Both modes keep the menu open on selection and support toggle (re-click to deselect). Default chip mode behavior is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/Dropdown/Dropdown.types.ts | 12 ++ .../Dropdown/__tests__/Dropdown.test.tsx | 129 ++++++++---------- .../MultiSelectedValues.tsx | 23 +++- .../components/Trigger/DropdownInput.tsx | 29 +++- .../components/Trigger/MultiSelectTrigger.tsx | 127 +++++++++++------ .../Dropdown/context/DropdownContext.types.ts | 3 + .../hooks/useDropdownMultiCombobox.ts | 106 ++++++++++++-- .../modes/DropdownMultiComboboxController.tsx | 17 ++- 8 files changed, 312 insertions(+), 134 deletions(-) diff --git a/packages/core/src/components/Dropdown/Dropdown.types.ts b/packages/core/src/components/Dropdown/Dropdown.types.ts index c877d82093..1cdb5b3c86 100644 --- a/packages/core/src/components/Dropdown/Dropdown.types.ts +++ b/packages/core/src/components/Dropdown/Dropdown.types.ts @@ -20,6 +20,18 @@ interface MultiSelectSpecifics * Callback fired when an option is removed in multi-select mode. Only available when multi is true. */ onOptionRemove?: (option: Item) => void; + /** + * If true, selected items are shown as a comma-separated text summary inside the input instead of chips. + * Satisfies WCAG 4.1.2 by exposing the combobox value to assistive technologies on focus. + * Only applies when searchable=true. + */ + textInput?: boolean; + /** + * If true, chips are always visible and support keyboard navigation: pressing Backspace or Left arrow + * from the input moves focus to the last chip; Left/Right navigates between chips; Delete/Backspace + * removes the focused chip. Only applies when searchable=true. + */ + interactiveChips?: boolean; /** * The function to call to render the selected value on single select mode. */ diff --git a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx index 81d75b91f1..7d5ea025c9 100644 --- a/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx +++ b/packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -604,16 +604,17 @@ describe("DropdownNew", () => { describe("multi-select mode", () => { it("should render a multi-select dropdown when the multi prop is true", () => { - const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ - multi: true + const { getByPlaceholderText, getByText } = renderDropdown({ + multi: true, + textInput: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); - const option1 = getByText("Option 1"); - fireEvent.click(option1); - expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); + fireEvent.click(getByText("Option 1")); + // In textInput mode selection is reflected in the input value, not as chips. + expect(input).toHaveValue("Option 1"); }); it("should allow selecting multiple items", () => { @@ -636,70 +637,67 @@ describe("DropdownNew", () => { ]); }); - it("should render chips for selected items", () => { - const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ - multi: true + it("should show selected items as a comma-separated summary in the input", () => { + const { getByPlaceholderText, getByText } = renderDropdown({ + multi: true, + textInput: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); fireEvent.click(getByText("Option 3")); - expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); - expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); + expect(input).toHaveValue("Option 1, Option 3"); }); - it("should remove an item when its chip is deleted", () => { + it("should remove an item when it is re-clicked in the dropdown", () => { const onChange = vi.fn(); - const { getByPlaceholderText, getByText, getAllByRole } = renderDropdown({ + const { getByPlaceholderText, getByText } = renderDropdown({ multi: true, + textInput: true, onChange }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); fireEvent.click(getByText("Option 3")); - const deleteButtons = getAllByRole("button").filter( - button => button.getAttribute("data-testid") === "chip-close" - ); - - fireEvent.click(deleteButtons[0]); + // Re-click Option 1 to deselect it. + fireEvent.click(getByText("Option 1")); - expect(onChange).toHaveBeenLastCalledWith( - expect.arrayContaining([expect.not.objectContaining({ value: "opt1" })]) - ); + expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ value: "opt3" })]); + expect(input).toHaveValue("Option 3"); }); it("should call onOptionRemove when an item is removed", () => { const onOptionRemove = vi.fn(); - const { getByPlaceholderText, getByText, getAllByRole } = renderDropdown({ + const { getByPlaceholderText, getByText } = renderDropdown({ multi: true, + textInput: true, onOptionRemove }); const input = getByPlaceholderText("Select an option"); fireEvent.click(input); + fireEvent.click(getByText("Option 1")); + // Re-click to deselect. fireEvent.click(getByText("Option 1")); - const deleteButtons = getAllByRole("button").filter(button => - button.getAttribute("data-testid")?.includes("close") - ); - fireEvent.click(deleteButtons[0]); expect(onOptionRemove).toHaveBeenCalledWith(expect.objectContaining({ value: "opt1", label: "Option 1" })); }); - it("should show selected chips without counter", () => { - const { getByPlaceholderText, getByText, queryByTestId, getByLabelText } = renderDropdown({ - multi: true + it("should show all selected items in the input value after closing", () => { + const { getByPlaceholderText, getByText } = renderDropdown({ + multi: true, + textInput: true }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); fireEvent.click(getByText("Option 1")); @@ -707,45 +705,37 @@ describe("DropdownNew", () => { fireEvent.keyDown(input, { key: "Escape", code: "Escape" }); - expect(getByLabelText("Option 1")).toBeInTheDocument(); - expect(getByLabelText("Option 3")).toBeInTheDocument(); - - expect(queryByTestId("dropdown-counter")).not.toBeInTheDocument(); + expect(input).toHaveValue("Option 1, Option 3"); }); - it("should show an overflow counter when more items are selected than can be displayed", () => { - const manyOptionsForCounter = [ + it("should show all selected items as comma-separated summary in the input", () => { + const manyOptions = [ { - label: "Overflow Group", + label: "Group", options: [ - { label: "Chip Item 1", value: "chip1" }, - { label: "Chip Item 2", value: "chip2" }, - { label: "Chip Item 3", value: "chip3" } + { label: "Item 1", value: "item1" }, + { label: "Item 2", value: "item2" }, + { label: "Item 3", value: "item3" } ] } ]; - const { getByPlaceholderText, getByText, getByTestId } = renderDropdown({ + const { getByPlaceholderText, getByText } = renderDropdown({ multi: true, - options: manyOptionsForCounter + textInput: true, + options: manyOptions }); - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; fireEvent.click(input); - fireEvent.click(getByText("Chip Item 1")); - fireEvent.click(getByText("Chip Item 2")); - fireEvent.click(getByText("Chip Item 3")); + fireEvent.click(getByText("Item 1")); + fireEvent.click(getByText("Item 2")); + fireEvent.click(getByText("Item 3")); fireEvent.keyDown(input, { key: "Escape", code: "Escape" }); - const counter = getByTestId("dropdown-overflow-counter"); - expect(counter).toBeInTheDocument(); - expect(counter).toHaveTextContent("+ 2"); - - expect(getByTestId("dropdown-chip-chip1")).not.toHaveAttribute("aria-hidden", "true"); - expect(getByTestId("dropdown-chip-chip2")).toHaveAttribute("aria-hidden", "true"); - expect(getByTestId("dropdown-chip-chip3")).toHaveAttribute("aria-hidden", "true"); + expect(input).toHaveValue("Item 1, Item 2, Item 3"); }); }); @@ -1072,10 +1062,11 @@ describe("DropdownNew", () => { }); it("should hide selected options from list when showSelectedOptions is false (multi select)", () => { - const { getByRole, getByTestId, getByPlaceholderText } = renderDropdown({ + const { getByRole, getByPlaceholderText } = renderDropdown({ options: showSelectedTestOptions, showSelectedOptions: false, multi: true, + textInput: true, placeholder: "Select multi" }); @@ -1084,7 +1075,7 @@ describe("DropdownNew", () => { let listbox = getByRole("listbox"); fireEvent.click(within(listbox).getByText("Option Alpha")); - expect(getByTestId("dropdown-chip-alpha")).toBeInTheDocument(); + expect(input).toHaveValue("Option Alpha"); listbox = getByRole("listbox"); expect(within(listbox).queryByText("Option Alpha")).not.toBeInTheDocument(); @@ -1092,7 +1083,7 @@ describe("DropdownNew", () => { expect(within(listbox).getByText("Option Gamma")).toBeInTheDocument(); fireEvent.click(within(listbox).getByText("Option Gamma")); - expect(getByTestId("dropdown-chip-gamma")).toBeInTheDocument(); + expect(input).toHaveValue("Option Alpha, Option Gamma"); listbox = getByRole("listbox"); expect(within(listbox).queryByText("Option Alpha")).not.toBeInTheDocument(); @@ -1101,24 +1092,25 @@ describe("DropdownNew", () => { }); it("should keep selected options in list when showSelectedOptions is true (multi select)", () => { - const { getByPlaceholderText, getByRole, getByTestId } = renderDropdown({ + const { getByPlaceholderText, getByRole } = renderDropdown({ options: showSelectedTestOptions, showSelectedOptions: true, multi: true, + textInput: true, placeholder: "Select multi true" }); - const input = getByPlaceholderText("Select multi true"); + const input = getByPlaceholderText("Select multi true") as HTMLInputElement; fireEvent.click(input); let listbox = getByRole("listbox"); fireEvent.click(within(listbox).getByText("Option Alpha")); - expect(getByTestId("dropdown-chip-alpha")).toBeInTheDocument(); + expect(input).toHaveValue("Option Alpha"); listbox = getByRole("listbox"); expect(within(listbox).getByText("Option Alpha")).toBeInTheDocument(); expect(within(listbox).getByText("Option Beta")).toBeInTheDocument(); fireEvent.click(within(listbox).getByText("Option Beta")); - expect(getByTestId("dropdown-chip-beta")).toBeInTheDocument(); + expect(input).toHaveValue("Option Alpha, Option Beta"); listbox = getByRole("listbox"); expect(within(listbox).getByText("Option Alpha")).toBeInTheDocument(); expect(within(listbox).getByText("Option Beta")).toBeInTheDocument(); @@ -1144,23 +1136,22 @@ describe("DropdownNew", () => { }); it("should work with multi select in box mode", () => { - const { getByRole, getByPlaceholderText, getByTestId } = renderDropdown({ + const { getByRole, getByPlaceholderText } = renderDropdown({ boxMode: true, multi: true, - searchable: true + searchable: true, + textInput: true }); - // Input should be visible - const input = getByPlaceholderText("Select an option"); + const input = getByPlaceholderText("Select an option") as HTMLInputElement; expect(input).toBeInTheDocument(); - // Menu should be visible const listbox = getByRole("listbox"); expect(listbox).toBeInTheDocument(); - // Select an option fireEvent.click(within(listbox).getByText("Option 1")); - expect(getByTestId("dropdown-chip-opt1")).toBeInTheDocument(); + // In textInput mode, selection is reflected in the input value. + expect(input).toHaveValue("Option 1"); }); it("should hide chevron in box mode", () => { diff --git a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx index 8ed9b62d04..c8d48d60e4 100644 --- a/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx +++ b/packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx @@ -17,6 +17,10 @@ type MultiSelectedValuesProps = { disabled?: boolean; readOnly?: boolean; minVisibleCount?: number; + /** Extra props (tabIndex, onKeyDown, etc.) to spread on each visible chip container. */ + getChipContainerProps?: (item: Item, index: number) => Record; + /** Ref forwarded to the +N overflow Chips element, for external keyboard focus management. */ + badgeRef?: React.Ref; }; function MultiSelectedValues>>({ @@ -25,7 +29,9 @@ function MultiSelectedValues>> renderInput, disabled, readOnly, - minVisibleCount = 0 + minVisibleCount = 0, + getChipContainerProps, + badgeRef }: MultiSelectedValuesProps) { const containerRef = useRef(null); const deductedSpaceRef = useRef(null); @@ -73,17 +79,23 @@ function MultiSelectedValues>> const chipElements = useMemo(() => { return selectedItems.map((item, index) => { const isVisible = index < visibleCount; + const extraProps = isVisible && getChipContainerProps ? getChipContainerProps(item, index) : {}; + const { ref: extraRef, ...extraAttrs } = extraProps; return (
{ + (itemRefs[index] as React.MutableRefObject).current = el; + if (typeof extraRef === "function") extraRef(el); + }} className={cx({ [styles.chipWrapperWithOverflow]: minVisibleCount !== undefined, [styles.hiddenChip]: !isVisible })} aria-hidden={!isVisible} data-testid={`dropdown-chip-${item.value}`} + {...extraAttrs} > >>
); }); - }, [selectedItems, visibleCount, onRemove, itemRefs, disabled, readOnly, minVisibleCount]); + }, [selectedItems, visibleCount, onRemove, itemRefs, disabled, readOnly, minVisibleCount, getChipContainerProps]); if (!selectedItems?.length) return null; @@ -123,6 +135,10 @@ function MultiSelectedValues>> }} onKeyDown={e => { e.stopPropagation(); + if (e.key === "ArrowLeft") { + e.preventDefault(); + (itemRefs[visibleCount - 1] as React.MutableRefObject)?.current?.focus(); + } }} onMouseDown={e => { e.stopPropagation(); @@ -138,6 +154,7 @@ function MultiSelectedValues>> addKeyboardHideShowTriggersByDefault > { +const DropdownInput = ({ + inputSize, + fullWidth, + onKeyDown: externalKeyDown, + inputRef: externalInputRef +}: { + inputSize?: "small" | "medium" | "large"; + fullWidth?: boolean; + onKeyDown?: React.KeyboardEventHandler; + inputRef?: RefObject; +}) => { const { inputValue, autoFocus, @@ -23,14 +33,19 @@ const DropdownInput = ({ inputSize, fullWidth }: { inputSize?: "small" | "medium isOpen, getDropdownProps, getLabelProps, - getInputProps + getInputProps, + interactiveChips } = useDropdownContext(); - const inputRef = useRef(null); + const internalRef = useRef(null); + const inputRef = externalInputRef ?? internalRef; const hasSelection = multi ? selectedItems.length > 0 : !!selectedItem; - const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction: isOpen }) : {}; + // interactiveChips: menu is always open, so isOpen would permanently suppress Backspace chip-nav. + // Instead suppress only when the input has text (Backspace should delete chars, not navigate chips). + const preventKeyAction = interactiveChips ? !!(inputValue && inputValue.length > 0) : isOpen; + const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction }) : {}; - return ( +return ( <> {searchable ? ( { const { selectedItems = [], contextOnOptionRemove, + getSelectedItemProps, multiline, disabled, readOnly, @@ -24,9 +25,94 @@ const MultiSelectTrigger = () => { label, getLabelProps, "aria-label": ariaLabel, - minVisibleCount + minVisibleCount, + textInput, + interactiveChips } = useDropdownContext(); + const showChips = selectedItems.length > 0 && (!textInput || readOnly); + const overflowBadgeRef = useRef(null); + + const renderTriggerContent = () => { + if (textInput) { + return ; + } + + if (interactiveChips && searchable && !readOnly) { + if (selectedItems.length === 0) { + return ; + } + return ( +
{ + if ( + e.key === "ArrowLeft" && + e.target instanceof HTMLInputElement && + !e.target.value && + overflowBadgeRef.current + ) { + overflowBadgeRef.current.focus(); + } + }} + > + contextOnOptionRemove?.(item)} + renderInput={() => } + getChipContainerProps={(item, index) => + getSelectedItemProps?.({ selectedItem: item, index }) ?? {} + } + badgeRef={overflowBadgeRef} + minVisibleCount={minVisibleCount} + /> +
+ ); + } + + // Default chips mode: original behavior. + if (showChips) { + return ( +
+ {!multiline ? ( + { + contextOnOptionRemove?.(item); + }} + renderInput={searchable ? () => : undefined} + minVisibleCount={minVisibleCount} + /> + ) : ( + + {selectedItems.map((item, index) => ( + +
+ { + contextOnOptionRemove?.(item); + }} + readOnly={readOnly} + disabled={disabled} + /> +
+ {index === selectedItems.length - 1 && } +
+ ))} +
+ )} +
+ ); + } + + return ; + }; + return (
{ }) : {})} > - {selectedItems.length > 0 ? ( -
- {!multiline ? ( - { - contextOnOptionRemove?.(item); - }} - renderInput={searchable ? () => : undefined} - minVisibleCount={minVisibleCount} - /> - ) : ( - - {selectedItems.map((item, index) => ( - -
- { - contextOnOptionRemove?.(item); - }} - readOnly={readOnly} - disabled={disabled} - /> -
- {index === selectedItems.length - 1 && } -
- ))} -
- )} -
- ) : ( - - )} + {renderTriggerContent()}
diff --git a/packages/core/src/components/Dropdown/context/DropdownContext.types.ts b/packages/core/src/components/Dropdown/context/DropdownContext.types.ts index 17c0cb764c..9c384e23ec 100644 --- a/packages/core/src/components/Dropdown/context/DropdownContext.types.ts +++ b/packages/core/src/components/Dropdown/context/DropdownContext.types.ts @@ -76,5 +76,8 @@ export interface DropdownContextProps void; removeSelectedItem?: (item: Item) => void; + getSelectedItemProps?: (options: { selectedItem: any; index: number }) => Record; isFocused?: boolean; + textInput?: boolean; + interactiveChips?: boolean; } diff --git a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts index 4982989f84..d03541feb0 100644 --- a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts +++ b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from "react"; +import { useMemo, useCallback, useRef } from "react"; import useDropdownFiltering from "./useDropdownFiltering"; import { useMultipleSelection, useCombobox } from "downshift"; import { type DropdownGroupOption } from "../Dropdown.types"; @@ -20,10 +20,18 @@ function useDropdownMultiCombobox onOptionSelect?: (option: T) => void, filterOption?: (option: T, inputValue: string) => boolean, showSelectedOptions?: boolean, - id?: string + id?: string, + onOptionRemove?: (option: T) => void, + textInput?: boolean, + interactiveChips?: boolean ) { // Use controlled value if provided, otherwise use internal state const currentSelectedItems = value !== undefined ? value : selectedItems; + // Used only in textInput/interactiveChips modes. The stateReducer resets selectedItem to null so + // onStateChange fires even on repeat clicks; this ref carries the original item across that reset. + const pendingToggleRef = useRef(null); + // textInput only: carries the clicked item across stateReducer's selectedItem:null reset. + const enableToggle = textInput; const { filteredOptions, filterOptions } = useDropdownFiltering( options, @@ -40,6 +48,16 @@ function useDropdownMultiCombobox setSelectedItems(selectedItems || []); } onChange?.(selectedItems || []); + }, + onStateChange: ({ type, selectedItem: removedItem }) => { + // Notify onOptionRemove for keyboard-driven chip deletion (× button uses contextOnOptionRemove). + if ( + (type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace || + type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete) && + removedItem + ) { + onOptionRemove?.(removedItem); + } } }); @@ -63,41 +81,101 @@ function useDropdownMultiCombobox isItemDisabled: item => Boolean(item.disabled), isOpen: isMenuOpen, initialIsOpen: autoFocus, - initialInputValue: inputValueProp || "", + initialInputValue: inputValueProp ?? (textInput ? currentSelectedItems.map(i => i.label).join(", ") : ""), id, onIsOpenChange: ({ isOpen }) => { + // Reset the text filter on any open/close change so the full list is always ready. + filterOptions(""); isOpen ? onMenuClose?.() : onMenuOpen?.(); }, - onInputValueChange: ({ inputValue }) => { - filterOptions(inputValue || ""); - onInputChange?.(inputValue); - }, + onInputValueChange: useCallback( + ({ inputValue, type }) => { + // Only filter on actual user typing. Downshift also writes values into the input on + // open/close/selection — those changes must not filter the list. + if (type === useCombobox.stateChangeTypes.InputChange) { + filterOptions(inputValue || ""); + } + onInputChange?.(inputValue); + }, + [onInputChange, filterOptions] + ), + // When enableToggle (textInput), stateReducer resets selectedItem to null so this fires with + // null and exits early; onStateChange + pendingToggleRef handle selection instead. onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { - if (!newSelectedItem) return; + if (enableToggle || !newSelectedItem) return; const existingItem = currentSelectedItems.find(item => item.value === newSelectedItem.value); if (existingItem) { removeSelectedItem(existingItem); + onOptionRemove?.(existingItem); } else { addSelectedItem(newSelectedItem); } onOptionSelect?.(newSelectedItem); filterOptions(""); }, + onStateChange: ({ type }) => { + if (!enableToggle) return; + if ( + type !== useCombobox.stateChangeTypes.ItemClick && + type !== useCombobox.stateChangeTypes.InputKeyDownEnter + ) + return; + + const clickedItem = pendingToggleRef.current; + pendingToggleRef.current = null; + if (!clickedItem) return; + const existingItem = currentSelectedItems.find(i => i.value === clickedItem.value); + if (existingItem) { + removeSelectedItem(existingItem); + onOptionRemove?.(existingItem); + } else { + addSelectedItem(clickedItem); + } + onOptionSelect?.(clickedItem); + filterOptions(""); + }, stateReducer: (state, actionAndChanges) => { - switch (actionAndChanges.type) { + const { type, changes } = actionAndChanges; + // null clears the input and restores the placeholder (original multi-select behavior). + // textInput mode shows a comma-separated summary instead. + const closedInputValue = textInput ? currentSelectedItems.map(i => i.label).join(", ") : null; + + switch (type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: - case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.ItemClick: { + if (enableToggle) { + const clickedItem = changes.selectedItem; + pendingToggleRef.current = clickedItem ?? null; + const newItems = clickedItem + ? currentSelectedItems.some(i => i.value === clickedItem.value) + ? currentSelectedItems.filter(i => i.value !== clickedItem.value) + : [...currentSelectedItems, clickedItem] + : currentSelectedItems; + const newInputValue = textInput ? newItems.map(i => i.label).join(", ") : null; + return { + ...changes, + selectedItem: null, + inputValue: newInputValue, + isOpen: true, + highlightedIndex: (changes.selectedItem?.index as number) ?? 0 + }; + } + // Default mode: original behavior — keep the menu open, clear input to restore placeholder. return { - ...actionAndChanges.changes, + ...changes, inputValue: null, isOpen: true, - highlightedIndex: (actionAndChanges.changes.selectedItem?.index as number) ?? 0 + highlightedIndex: (changes.selectedItem?.index as number) ?? 0 }; + } case useCombobox.stateChangeTypes.InputBlur: case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: - return { ...actionAndChanges.changes, inputValue: null }; + return { ...changes, inputValue: closedInputValue }; default: - return actionAndChanges.changes; + if (!changes.isOpen && state.isOpen) { + return { ...changes, inputValue: closedInputValue }; + } + return changes; } } }); diff --git a/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx b/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx index edbb8bdcb4..3c403c98c2 100644 --- a/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx +++ b/packages/core/src/components/Dropdown/modes/DropdownMultiComboboxController.tsx @@ -35,7 +35,9 @@ const DropdownMultiComboboxController = ( options, multiSelectedItemsState, @@ -74,7 +77,10 @@ const DropdownMultiComboboxController = = { @@ -124,6 +130,7 @@ const DropdownMultiComboboxController = ; From 2e64c64dfee17da87eedf40329f9cc1a07b3d5ac Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:06:53 +0300 Subject: [PATCH 09/16] docs(Dropdown): add multi-select accessibility modes page Storybook MDX page and stories for the textInput and interactiveChips props explaining how each addresses the WCAG 4.1.2 gap in default multi-select (empty input announced as blank by screen readers). Covers: - Side-by-side comparison of all three modes - textInput: comma-separated value in the input, exposed on mount, controlled usage, trade-offs - interactiveChips: keyboard navigation table, overflow (+N badge), trade-offs - Decision guide for choosing between modes - Common a11y props table (label, clearAriaLabel, error, etc.) Co-Authored-By: Claude Sonnet 4.6 --- .../Dropdown/DropdownMultiSelectA11y.mdx | 125 +++++++++ .../DropdownMultiSelectA11y.stories.tsx | 247 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx create mode 100644 packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx new file mode 100644 index 0000000000..5a8fd6c57b --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx @@ -0,0 +1,125 @@ +import { Meta, Canvas } from "@storybook/blocks"; +import * as DropdownMultiSelectA11yStories from "./DropdownMultiSelectA11y.stories"; + + + +# Multi-select — Accessibility modes + +The multi-select Dropdown (`multi`) provides two props that directly address assistive-technology requirements: `textInput` and `interactiveChips`. This page explains the problem each solves and when to choose one over the other. + +### Import + +```js +import { Dropdown } from "@vibe/core"; +``` + + + +## The problem with default multi-select + +In the default chip mode, selected items are rendered as visual chips **outside** the input. The input itself stays empty at all times. Because screen readers read an input's **value** — not the chips around it — a field with three selections is still announced as _"…, blank"_ when focused. + +This violates **WCAG 4.1.2 Name, Role, Value (Level A)**, which requires the current value of a form control to be programmatically determinable. + +--- + +## `textInput` — comma-separated value inside the input + + + +When `textInput` is set, selected items are **not shown as chips**. Instead, their labels are joined as a comma-separated string and placed directly inside the ``: + +> _"Teams, Design, Engineering, combo box"_ + +This satisfies WCAG 4.1.2 because the value is in the input — where assistive technologies expect it. + +### Value exposed on mount + +A pre-selected `defaultValue` or controlled `value` is reflected in the input immediately, without any user interaction. + + + +### Controlled usage + + + +### What happens while typing + +When the user starts typing to search, the summary is temporarily replaced by the search text. On blur or close the full summary is restored. The filter applies to the option list only — existing selections are preserved. + +### Trade-offs + +| Aspect | Behavior | +| --- | --- | +| Visual representation | Text only — no chips | +| Screen reader on focus | Full selection announced (e.g. "Design, Engineering") | +| `defaultValue` / `value` on mount | Announced immediately | +| Individual chip removal | Not available — use the clear button or re-click items | + +Use `textInput` when **WCAG 4.1.2 compliance is the priority** and the chip-removal UX is not needed. + +--- + +## `interactiveChips` — chips with full keyboard navigation + + + +When `interactiveChips` is set, chips stay visible and are **keyboard-navigable**. A sighted keyboard user can reach each chip and remove it without a mouse — addressing the common accessibility gap where chips are visual-only controls. + +### Keyboard navigation + + + +| Key | Where | Action | +| --- | --- | --- | +| `ArrowLeft` | Input (empty) | Move focus to the last chip | +| `ArrowLeft` / `ArrowRight` | Focused chip | Move between chips | +| `Backspace` / `Delete` | Focused chip | Remove that chip | +| `Backspace` | Input (empty) | Move focus to the last chip | +| `Enter` / `Space` / click | Any chip | No action (not a toggle in this mode — use the menu) | + +### Overflow — the +N badge + +When chips exceed the available width, a **+N badge** groups the overflow. Clicking the badge opens a dialog listing the hidden chips, each with its own remove button. + + + +### Trade-offs + +| Aspect | Behavior | +| --- | --- | +| Visual representation | Chips (same as default) | +| Keyboard chip removal | Yes — ArrowLeft + Backspace/Delete | +| Screen reader on focus | Input value is empty (same gap as default mode) | +| Overflow | +N badge with dialog | + +Use `interactiveChips` when **keyboard-accessible chip removal** is the priority and the visual chip layout should be preserved. + +--- + +## Choosing between them + +| Need | Recommended prop | +| --- | --- | +| Screen reader announces the full selection on focus | `textInput` | +| WCAG 4.1.2 compliance | `textInput` | +| Pre-selected value readable on page load | `textInput` | +| Users need to remove individual chips without a mouse | `interactiveChips` | +| Chip-based visual layout must be preserved | `interactiveChips` | +| Both requirements | Use `textInput` — combine with `onOptionRemove` and an external chip list if individual removal is also needed | + +--- + +## Common accessibility props + +These props apply to both modes (and to the default chip mode). + +| Prop | Purpose | Notes | +| --- | --- | --- | +| `label` | Visible text label, associated via `aria-labelledby` | **Preferred** accessible name. Always provide a label. | +| `aria-label` | Accessible name when there is no visible label | Use only when a visible label is not possible. | +| `clearAriaLabel` | Accessible name for the clear (✕) button | **Required when `clearable`.** Without it the button is an icon-only control with no name. | +| `error` | Puts the field in an error state (`aria-invalid`) | Always pair with `helperText` describing the error — color alone fails 1.4.1. | +| `helperText` | Descriptive text associated via `aria-describedby` | Use for instructions and error messages. | +| `required` | Marks the field required (`aria-required`) | Pair with form-level validation. | +| `noOptionsMessage` | Text announced when a search yields no results | Give a meaningful message (e.g. "No teams found"). | diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx new file mode 100644 index 0000000000..95f9b4bd95 --- /dev/null +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -0,0 +1,247 @@ +import React, { useMemo, useState } from "react"; +import { type Meta, type StoryObj } from "@storybook/react"; +import { createStoryMetaSettingsDecorator } from "../../../utils/createStoryMetaSettingsDecorator"; +import { Dropdown, type BaseDropdownProps, type DropdownOption, Flex, Text } from "@vibe/core"; + +type Story = StoryObj; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: Dropdown, + actionPropsArray: ["onChange", "onOptionSelect", "onOptionRemove", "onClear"] +}); + +const meta: Meta = { + title: "Components/Dropdown/Multi-select accessibility", + component: Dropdown, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators +}; + +export default meta; + +const teamOptions = [ + { value: "design", label: "Design" }, + { value: "engineering", label: "Engineering" }, + { value: "marketing", label: "Marketing" }, + { value: "product", label: "Product" }, + { value: "sales", label: "Sales" } +]; + +const dropdownWrapper = (children: React.ReactNode) => ( +
{children}
+); + +export const Overview: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return ( + + + + Default chips + + + Selected items shown as chips. Input is always empty — screen readers announce the field as blank. + +
+ +
+
+ + + textInput + + + Selection shown as a comma-separated summary inside the input. Screen readers announce the full value. + +
+ +
+
+ + + interactiveChips + + + Chips stay visible. Each chip is keyboard-navigable and removable via ArrowLeft / Backspace. + +
+ +
+
+
+ ); + }, + parameters: { + docs: { liveEdit: { isEnabled: false } } + } +}; + +export const TextInputBasic: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return dropdownWrapper( + + ); + } +}; + +export const TextInputWithDefaultValue: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return ( + + + Tab to the field — screen readers immediately announce "Design, Engineering" without any interaction needed. + +
+ +
+
+ ); + } +}; + +export const TextInputControlled: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + const [value, setValue] = useState([]); + + return ( + + Selected: {value.length ? value.map(v => v.label).join(", ") : "none"} +
+ setValue(items)} + onClear={() => setValue([])} + clearAriaLabel="Clear" + /> +
+
+ ); + } +}; + +export const InteractiveChipsBasic: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return dropdownWrapper( + + ); + } +}; + +export const InteractiveChipsKeyboardNav: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return ( + + + Select two or more items, then use ArrowLeft from the input to move focus to a chip. Press Backspace or Delete + to remove it. + +
+ +
+
+ ); + } +}; + +export const InteractiveChipsWithOverflow: Story = { + render: () => { + const options = useMemo(() => teamOptions, []); + return ( + + + When chips overflow the available width, a +N badge groups the rest. Click it to see all selected items. + +
+ +
+
+ ); + } +}; From 4fcb1dc81f8686137a2e4381094987aff5be3807 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:27:22 +0300 Subject: [PATCH 10/16] docs(Dropdown): simplify multi-select a11y page to 2 proposed solutions Co-Authored-By: Claude Sonnet 4.6 --- .../Dropdown/DropdownMultiSelectA11y.mdx | 113 +------- .../DropdownMultiSelectA11y.stories.tsx | 249 +++--------------- 2 files changed, 50 insertions(+), 312 deletions(-) diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx index 5a8fd6c57b..4e1f9f5071 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx @@ -3,123 +3,32 @@ import * as DropdownMultiSelectA11yStories from "./DropdownMultiSelectA11y.stori -# Multi-select — Accessibility modes +# Multi-select accessibility -The multi-select Dropdown (`multi`) provides two props that directly address assistive-technology requirements: `textInput` and `interactiveChips`. This page explains the problem each solves and when to choose one over the other. +By default, a multi-select Dropdown renders selected items as chips **outside** the input element. The input itself stays empty at all times. Screen readers read an input's value — not the chips around it — so a field with three selections is still announced as _"blank"_ when focused. This breaks **WCAG 4.1.2 Name, Role, Value**, which requires the current value of a form control to be programmatically determinable. -### Import - -```js -import { Dropdown } from "@vibe/core"; -``` - - - -## The problem with default multi-select - -In the default chip mode, selected items are rendered as visual chips **outside** the input. The input itself stays empty at all times. Because screen readers read an input's **value** — not the chips around it — a field with three selections is still announced as _"…, blank"_ when focused. - -This violates **WCAG 4.1.2 Name, Role, Value (Level A)**, which requires the current value of a form control to be programmatically determinable. +We propose 2 solutions: --- -## `textInput` — comma-separated value inside the input +## Solution 1 — textInput -When `textInput` is set, selected items are **not shown as chips**. Instead, their labels are joined as a comma-separated string and placed directly inside the ``: - -> _"Teams, Design, Engineering, combo box"_ - -This satisfies WCAG 4.1.2 because the value is in the input — where assistive technologies expect it. - -### Value exposed on mount +Instead of chips, selected labels are joined as a comma-separated string and written directly into the ``. When a screen reader focuses the field it announces the full selection — _"Chip one, Chip two, combo box"_ — satisfying WCAG 4.1.2. -A pre-selected `defaultValue` or controlled `value` is reflected in the input immediately, without any user interaction. +When the user starts typing, the summary is temporarily replaced by the search text. On close or blur it is restored. - - -### Controlled usage - - - -### What happens while typing - -When the user starts typing to search, the summary is temporarily replaced by the search text. On blur or close the full summary is restored. The filter applies to the option list only — existing selections are preserved. - -### Trade-offs - -| Aspect | Behavior | -| --- | --- | -| Visual representation | Text only — no chips | -| Screen reader on focus | Full selection announced (e.g. "Design, Engineering") | -| `defaultValue` / `value` on mount | Announced immediately | -| Individual chip removal | Not available — use the clear button or re-click items | - -Use `textInput` when **WCAG 4.1.2 compliance is the priority** and the chip-removal UX is not needed. +**Use this when:** accurate screen reader announcement of the current value is the priority. --- -## `interactiveChips` — chips with full keyboard navigation +## Solution 2 — interactiveChips -When `interactiveChips` is set, chips stay visible and are **keyboard-navigable**. A sighted keyboard user can reach each chip and remove it without a mouse — addressing the common accessibility gap where chips are visual-only controls. - -### Keyboard navigation - - - -| Key | Where | Action | -| --- | --- | --- | -| `ArrowLeft` | Input (empty) | Move focus to the last chip | -| `ArrowLeft` / `ArrowRight` | Focused chip | Move between chips | -| `Backspace` / `Delete` | Focused chip | Remove that chip | -| `Backspace` | Input (empty) | Move focus to the last chip | -| `Enter` / `Space` / click | Any chip | No action (not a toggle in this mode — use the menu) | - -### Overflow — the +N badge - -When chips exceed the available width, a **+N badge** groups the overflow. Clicking the badge opens a dialog listing the hidden chips, each with its own remove button. - - - -### Trade-offs - -| Aspect | Behavior | -| --- | --- | -| Visual representation | Chips (same as default) | -| Keyboard chip removal | Yes — ArrowLeft + Backspace/Delete | -| Screen reader on focus | Input value is empty (same gap as default mode) | -| Overflow | +N badge with dialog | - -Use `interactiveChips` when **keyboard-accessible chip removal** is the priority and the visual chip layout should be preserved. - ---- - -## Choosing between them - -| Need | Recommended prop | -| --- | --- | -| Screen reader announces the full selection on focus | `textInput` | -| WCAG 4.1.2 compliance | `textInput` | -| Pre-selected value readable on page load | `textInput` | -| Users need to remove individual chips without a mouse | `interactiveChips` | -| Chip-based visual layout must be preserved | `interactiveChips` | -| Both requirements | Use `textInput` — combine with `onOptionRemove` and an external chip list if individual removal is also needed | - ---- - -## Common accessibility props +Chips remain visible but each one becomes a focusable, keyboard-operable control. A keyboard-only user can navigate to any chip and remove it without a mouse — something the default chip mode does not support because the chips are purely visual. -These props apply to both modes (and to the default chip mode). +Keyboard navigation: press **ArrowLeft** from the input to move focus to the last chip, then **ArrowLeft / ArrowRight** to move between chips, and **Backspace** or **Delete** to remove the focused chip. -| Prop | Purpose | Notes | -| --- | --- | --- | -| `label` | Visible text label, associated via `aria-labelledby` | **Preferred** accessible name. Always provide a label. | -| `aria-label` | Accessible name when there is no visible label | Use only when a visible label is not possible. | -| `clearAriaLabel` | Accessible name for the clear (✕) button | **Required when `clearable`.** Without it the button is an icon-only control with no name. | -| `error` | Puts the field in an error state (`aria-invalid`) | Always pair with `helperText` describing the error — color alone fails 1.4.1. | -| `helperText` | Descriptive text associated via `aria-describedby` | Use for instructions and error messages. | -| `required` | Marks the field required (`aria-required`) | Pair with form-level validation. | -| `noOptionsMessage` | Text announced when a search yields no results | Give a meaningful message (e.g. "No teams found"). | +**Use this when:** preserving the chip layout is important and keyboard-accessible individual chip removal is needed. diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx index 95f9b4bd95..6e6a4483f8 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -1,7 +1,7 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { type Meta, type StoryObj } from "@storybook/react"; import { createStoryMetaSettingsDecorator } from "../../../utils/createStoryMetaSettingsDecorator"; -import { Dropdown, type BaseDropdownProps, type DropdownOption, Flex, Text } from "@vibe/core"; +import { Dropdown } from "@vibe/core"; type Story = StoryObj; @@ -19,229 +19,58 @@ const meta: Meta = { export default meta; -const teamOptions = [ - { value: "design", label: "Design" }, - { value: "engineering", label: "Engineering" }, - { value: "marketing", label: "Marketing" }, - { value: "product", label: "Product" }, - { value: "sales", label: "Sales" } -]; - -const dropdownWrapper = (children: React.ReactNode) => ( -
{children}
-); - -export const Overview: Story = { - render: () => { - const options = useMemo(() => teamOptions, []); - return ( - - - - Default chips - - - Selected items shown as chips. Input is always empty — screen readers announce the field as blank. - -
- -
-
- - - textInput - - - Selection shown as a comma-separated summary inside the input. Screen readers announce the full value. - -
- -
-
- - - interactiveChips - - - Chips stay visible. Each chip is keyboard-navigable and removable via ArrowLeft / Backspace. - -
- -
-
-
- ); - }, - parameters: { - docs: { liveEdit: { isEnabled: false } } - } -}; - export const TextInputBasic: Story = { render: () => { - const options = useMemo(() => teamOptions, []); - return dropdownWrapper( - + const options = useMemo( + () => [ + { value: "1", label: "Chip one" }, + { value: "2", label: "Chip two" }, + { value: "3", label: "Chip three" }, + { value: "4", label: "Chip four" } + ], + [] ); - } -}; -export const TextInputWithDefaultValue: Story = { - render: () => { - const options = useMemo(() => teamOptions, []); return ( - - - Tab to the field — screen readers immediately announce "Design, Engineering" without any interaction needed. - -
- -
-
- ); - } -}; - -export const TextInputControlled: Story = { - render: () => { - const options = useMemo(() => teamOptions, []); - const [value, setValue] = useState([]); - - return ( - - Selected: {value.length ? value.map(v => v.label).join(", ") : "none"} -
- setValue(items)} - onClear={() => setValue([])} - clearAriaLabel="Clear" - /> -
-
+
+ +
); } }; export const InteractiveChipsBasic: Story = { render: () => { - const options = useMemo(() => teamOptions, []); - return dropdownWrapper( - + const options = useMemo( + () => [ + { value: "1", label: "Chip one" }, + { value: "2", label: "Chip two" }, + { value: "3", label: "Chip three" }, + { value: "4", label: "Chip four" } + ], + [] ); - } -}; -export const InteractiveChipsKeyboardNav: Story = { - render: () => { - const options = useMemo(() => teamOptions, []); return ( - - - Select two or more items, then use ArrowLeft from the input to move focus to a chip. Press Backspace or Delete - to remove it. - -
- -
-
+
+ +
); } }; -export const InteractiveChipsWithOverflow: Story = { - render: () => { - const options = useMemo(() => teamOptions, []); - return ( - - - When chips overflow the available width, a +N badge groups the rest. Click it to see all selected items. - -
- -
-
- ); - } -}; + From 1769c1d8638462765210be1b6653279fddf70de2 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:28:32 +0300 Subject: [PATCH 11/16] [prerelease] From 82168c5fce94d0923c82449f6c16a9cf7661004b Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:42:52 +0300 Subject: [PATCH 12/16] fix(Dropdown): use selectedItems diff to find removed item in useMultipleSelection onStateChange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UseMultipleSelectionStateChange has no selectedItem property — diff old vs new selectedItems array instead. Co-Authored-By: Claude Sonnet 4.6 --- .../Dropdown/hooks/useDropdownMultiCombobox.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts index d03541feb0..ed5246b0e1 100644 --- a/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts +++ b/packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts @@ -49,14 +49,17 @@ function useDropdownMultiCombobox } onChange?.(selectedItems || []); }, - onStateChange: ({ type, selectedItem: removedItem }) => { + onStateChange: ({ type, selectedItems: newSelectedItems }) => { // Notify onOptionRemove for keyboard-driven chip deletion (× button uses contextOnOptionRemove). if ( (type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace || type === useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete) && - removedItem + newSelectedItems ) { - onOptionRemove?.(removedItem); + const removedItem = currentSelectedItems.find( + item => !newSelectedItems.some(si => si.value === item.value) + ); + if (removedItem) onOptionRemove?.(removedItem); } } }); From 2620010ed2b6705260fc4e48de57bde0599bf5ab Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:44:30 +0300 Subject: [PATCH 13/16] [prerelease] From f27928626734ab33b69c203a177c975130eee6c3 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Fri, 12 Jun 2026 01:47:27 +0300 Subject: [PATCH 14/16] docs(Dropdown): increase multi-select a11y story width to 600px Co-Authored-By: Claude Sonnet 4.6 --- .../components/Dropdown/DropdownMultiSelectA11y.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx index 6e6a4483f8..a73c0534ab 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -32,7 +32,7 @@ export const TextInputBasic: Story = { ); return ( -
+
+
Date: Mon, 15 Jun 2026 13:29:25 +0300 Subject: [PATCH 15/16] docs(Dropdown): clarify multi-select a11y problem and add chip overflow example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State the major issue prominently (selected chips live outside the input, so a multi-select with selections is announced as "blank" — failing WCAG 4.1.2), and add an InteractiveChipsOverflow example showing chips collapsing into a "+N" counter via minVisibleCount. Co-Authored-By: Claude Opus 4.8 --- .../Dropdown/DropdownMultiSelectA11y.mdx | 16 ++++++++-- .../DropdownMultiSelectA11y.stories.tsx | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx index 4e1f9f5071..e733e445ae 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.mdx @@ -5,9 +5,15 @@ import * as DropdownMultiSelectA11yStories from "./DropdownMultiSelectA11y.stori # Multi-select accessibility -By default, a multi-select Dropdown renders selected items as chips **outside** the input element. The input itself stays empty at all times. Screen readers read an input's value — not the chips around it — so a field with three selections is still announced as _"blank"_ when focused. This breaks **WCAG 4.1.2 Name, Role, Value**, which requires the current value of a form control to be programmatically determinable. +## The problem -We propose 2 solutions: +A multi-select Dropdown shows the selected values as **chips rendered next to the input — not inside it**. The `` element itself stays empty at all times. + +Assistive technologies announce the **value of the input**, not the chips beside it. So when a screen-reader user focuses a field that already has three items selected, it is announced as _**"blank"**_ — they have no way to know what is selected. + +> **The major issue:** the current value of the field is **not programmatically determinable**, which fails **WCAG 4.1.2 Name, Role, Value (Level A)**. For a screen-reader user this is a complete barrier — they cannot tell what they have selected. + +The two solutions below each expose the selection to assistive technology in a different way. --- @@ -32,3 +38,9 @@ Chips remain visible but each one becomes a focusable, keyboard-operable control Keyboard navigation: press **ArrowLeft** from the input to move focus to the last chip, then **ArrowLeft / ArrowRight** to move between chips, and **Backspace** or **Delete** to remove the focused chip. **Use this when:** preserving the chip layout is important and keyboard-accessible individual chip removal is needed. + +### Chip overflow + +When there are more selections than fit, the extra chips collapse into a **"+N" counter** (controlled by `minVisibleCount`). The visible chips stay individually focusable and removable via the keyboard, and the counter communicates how many more are selected. + + diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx index a73c0534ab..fb1eb6fc36 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -73,4 +73,35 @@ export const InteractiveChipsBasic: Story = { } }; +export const InteractiveChipsOverflow: Story = { + render: () => { + const options = useMemo( + () => [ + { value: "1", label: "Chip one" }, + { value: "2", label: "Chip two" }, + { value: "3", label: "Chip three" }, + { value: "4", label: "Chip four" }, + { value: "5", label: "Chip five" }, + { value: "6", label: "Chip six" } + ], + [] + ); + + return ( +
+ +
+ ); + } +}; + From 30a614745bf517237e610b400b54d818e31b9cd6 Mon Sep 17 00:00:00 2001 From: Rivka Ungar Date: Mon, 15 Jun 2026 13:52:03 +0300 Subject: [PATCH 16/16] docs(Dropdown): widen overflow chip example to 350px; fix MultiSelectTrigger formatting Co-Authored-By: Claude Opus 4.8 --- .../Dropdown/components/Trigger/MultiSelectTrigger.tsx | 4 +--- .../components/Dropdown/DropdownMultiSelectA11y.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx b/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx index 4888296307..7b77765d12 100644 --- a/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx +++ b/packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx @@ -62,9 +62,7 @@ const MultiSelectTrigger = () => { selectedItems={selectedItems} onRemove={item => contextOnOptionRemove?.(item)} renderInput={() => } - getChipContainerProps={(item, index) => - getSelectedItemProps?.({ selectedItem: item, index }) ?? {} - } + getChipContainerProps={(item, index) => getSelectedItemProps?.({ selectedItem: item, index }) ?? {}} badgeRef={overflowBadgeRef} minVisibleCount={minVisibleCount} /> diff --git a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx index fb1eb6fc36..2a4db43697 100644 --- a/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx +++ b/packages/docs/src/pages/components/Dropdown/DropdownMultiSelectA11y.stories.tsx @@ -88,7 +88,7 @@ export const InteractiveChipsOverflow: Story = { ); return ( -
+