Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c3a4c33
fix(Dropdown): keep selected value inside input for searchable single…
rivka-ungar Jun 10, 2026
6edf9ba
docs(Dropdown): add dedicated Searchable single select storybook page
rivka-ungar Jun 10, 2026
3417b82
docs(Dropdown): add searchable single select accessibility reference
rivka-ungar Jun 10, 2026
11b7f86
docs(Dropdown): make a11y reference a Storybook page, trim to essentials
rivka-ungar Jun 10, 2026
127c22d
fix(Dropdown): remove leftover selected-value overlay for searchable …
rivka-ungar Jun 11, 2026
d0ea70b
docs(Dropdown): document selected-value behavior change and add trade…
rivka-ungar Jun 11, 2026
8b0f582
fix(docs): escape MDX expression in searchable single select page
rivka-ungar Jun 11, 2026
ec82022
feat(Dropdown): add textInput and interactiveChips multi-select modes
rivka-ungar Jun 11, 2026
2e64c64
docs(Dropdown): add multi-select accessibility modes page
rivka-ungar Jun 11, 2026
4fcb1dc
docs(Dropdown): simplify multi-select a11y page to 2 proposed solutions
rivka-ungar Jun 11, 2026
1769c1d
[prerelease]
rivka-ungar Jun 11, 2026
82168c5
fix(Dropdown): use selectedItems diff to find removed item in useMult…
rivka-ungar Jun 11, 2026
2620010
[prerelease]
rivka-ungar Jun 11, 2026
f279286
docs(Dropdown): increase multi-select a11y story width to 600px
rivka-ungar Jun 11, 2026
18df1ce
docs(Dropdown): clarify multi-select a11y problem and add chip overfl…
rivka-ungar Jun 15, 2026
30a6147
docs(Dropdown): widen overflow chip example to 350px; fix MultiSelect…
rivka-ungar Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/src/components/Dropdown/Dropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ interface MultiSelectSpecifics<Item extends BaseItemData<Record<string, unknown>
* 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.
*/
Expand Down
155 changes: 73 additions & 82 deletions packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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<BaseItemData<Record<string, unknown>>>[] = [
{
label: "Group 1",
Expand All @@ -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();

Expand Down Expand Up @@ -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", () => {
Expand All @@ -636,116 +637,105 @@ 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"));
fireEvent.click(getByText("Option 3"));

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");
});
});

Expand Down Expand Up @@ -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"
});

Expand All @@ -1084,15 +1075,15 @@ 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();
expect(within(listbox).getByText("Option Beta")).toBeInTheDocument();
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();
Expand All @@ -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();
Expand All @@ -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", () => {
Expand Down
Loading
Loading