fix(combo-box): address accessibility issues (#2186)

Supports #2172
This commit is contained in:
Eric Liu 2025-08-19 10:35:18 -07:00 committed by GitHub
commit 2fc884caca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 57 deletions

View file

@ -116,7 +116,6 @@
import WarningFilled from "../icons/WarningFilled.svelte"; import WarningFilled from "../icons/WarningFilled.svelte";
import WarningAltFilled from "../icons/WarningAltFilled.svelte"; import WarningAltFilled from "../icons/WarningAltFilled.svelte";
import ListBox from "../ListBox/ListBox.svelte"; import ListBox from "../ListBox/ListBox.svelte";
import ListBoxField from "../ListBox/ListBoxField.svelte";
import ListBoxMenu from "../ListBox/ListBoxMenu.svelte"; import ListBoxMenu from "../ListBox/ListBoxMenu.svelte";
import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte"; import ListBoxMenuIcon from "../ListBox/ListBoxMenuIcon.svelte";
import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte"; import ListBoxMenuItem from "../ListBox/ListBoxMenuItem.svelte";
@ -254,22 +253,12 @@
{warn} {warn}
{warnText} {warnText}
> >
<ListBoxField <div class:bx--list-box__field={true}>
role="button"
aria-expanded={open}
on:click={async () => {
if (disabled) return;
open = true;
await tick();
ref.focus();
}}
{id}
{disabled}
{translateWithId}
>
<input <input
bind:this={ref} bind:this={ref}
bind:value bind:value
type="text"
role="combobox"
tabindex="0" tabindex="0"
autocomplete="off" autocomplete="off"
aria-autocomplete="list" aria-autocomplete="list"
@ -287,6 +276,10 @@
class:bx--text-input={true} class:bx--text-input={true}
class:bx--text-input--light={light} class:bx--text-input--light={light}
class:bx--text-input--empty={value === ""} class:bx--text-input--empty={value === ""}
on:click={() => {
if (disabled) return;
open = true;
}}
on:input={({ target }) => { on:input={({ target }) => {
if (!open && target.value.length > 0) { if (!open && target.value.length > 0) {
open = true; open = true;
@ -384,7 +377,7 @@
{translateWithId} {translateWithId}
{open} {open}
/> />
</ListBoxField> </div>
{#if open} {#if open}
<ListBoxMenu aria-label={ariaLabel} {id} on:scroll bind:ref={listRef}> <ListBoxMenu aria-label={ariaLabel} {id} on:scroll bind:ref={listRef}>
{#each filteredItems as item, i (item.id)} {#each filteredItems as item, i (item.id)}

View file

@ -4,6 +4,7 @@ import ComboBox from "./ComboBox.test.svelte";
import ComboBoxCustom from "./ComboBoxCustom.test.svelte"; import ComboBoxCustom from "./ComboBoxCustom.test.svelte";
describe("ComboBox", () => { describe("ComboBox", () => {
const getInput = () => screen.getByRole("combobox");
const getClearButton = () => const getClearButton = () =>
screen.getByRole("button", { name: "Clear selected item" }); screen.getByRole("button", { name: "Clear selected item" });
@ -15,15 +16,13 @@ describe("ComboBox", () => {
render(ComboBox); render(ComboBox);
expect(screen.getByText("Contact")).toBeInTheDocument(); expect(screen.getByText("Contact")).toBeInTheDocument();
const input = screen.getByRole("textbox"); expect(getInput()).toHaveAttribute("placeholder", "Select contact method");
expect(input).toHaveAttribute("placeholder", "Select contact method");
}); });
it("should open menu on click", async () => { it("should open menu on click", async () => {
render(ComboBox); render(ComboBox);
const input = screen.getByRole("textbox"); await user.click(getInput());
await user.click(input);
const dropdown = screen.getAllByRole("listbox")[1]; const dropdown = screen.getAllByRole("listbox")[1];
expect(dropdown).toBeVisible(); expect(dropdown).toBeVisible();
@ -33,20 +32,20 @@ describe("ComboBox", () => {
const consoleLog = vi.spyOn(console, "log"); const consoleLog = vi.spyOn(console, "log");
render(ComboBox); render(ComboBox);
await user.click(screen.getByRole("textbox")); await user.click(getInput());
await user.click(screen.getByText("Email")); await user.click(screen.getByText("Email"));
expect(consoleLog).toHaveBeenCalledWith("select", { expect(consoleLog).toHaveBeenCalledWith("select", {
selectedId: "1", selectedId: "1",
selectedItem: { id: "1", text: "Email" }, selectedItem: { id: "1", text: "Email" },
}); });
expect(screen.getByRole("textbox")).toHaveValue("Email"); expect(getInput()).toHaveValue("Email");
}); });
it("should handle keyboard navigation", async () => { it("should handle keyboard navigation", async () => {
render(ComboBox); render(ComboBox);
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
await user.keyboard("{ArrowDown}"); await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
@ -66,7 +65,7 @@ describe("ComboBox", () => {
await user.click(getClearButton()); await user.click(getClearButton());
expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String)); expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String));
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
}); });
it("should handle clear selection via keyboard navigation (Enter)", async () => { it("should handle clear selection via keyboard navigation (Enter)", async () => {
@ -79,7 +78,7 @@ describe("ComboBox", () => {
}); });
expect(consoleLog).not.toHaveBeenCalled(); expect(consoleLog).not.toHaveBeenCalled();
expect(screen.getByRole("textbox")).toHaveValue("Email"); expect(getInput()).toHaveValue("Email");
const clearButton = getClearButton(); const clearButton = getClearButton();
clearButton.focus(); clearButton.focus();
@ -87,7 +86,7 @@ describe("ComboBox", () => {
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String)); expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String));
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
}); });
it("should handle clear selection via keyboard navigation (Space)", async () => { it("should handle clear selection via keyboard navigation (Space)", async () => {
@ -100,7 +99,7 @@ describe("ComboBox", () => {
}); });
expect(consoleLog).not.toHaveBeenCalled(); expect(consoleLog).not.toHaveBeenCalled();
expect(screen.getByRole("textbox")).toHaveValue("Email"); expect(getInput()).toHaveValue("Email");
const clearButton = getClearButton(); const clearButton = getClearButton();
clearButton.focus(); clearButton.focus();
@ -108,7 +107,7 @@ describe("ComboBox", () => {
await user.keyboard(" "); await user.keyboard(" ");
expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String)); expect(consoleLog).toHaveBeenCalledWith("clear", expect.any(String));
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
}); });
it("should use custom translations when translateWithId is provided", () => { it("should use custom translations when translateWithId is provided", () => {
@ -134,7 +133,7 @@ describe("ComboBox", () => {
it("should handle disabled state", () => { it("should handle disabled state", () => {
render(ComboBox, { props: { disabled: true } }); render(ComboBox, { props: { disabled: true } });
expect(screen.getByRole("textbox")).toBeDisabled(); expect(getInput()).toBeDisabled();
expect(screen.getByText("Contact")).toHaveClass("bx--label--disabled"); expect(screen.getByText("Contact")).toHaveClass("bx--label--disabled");
}); });
@ -181,7 +180,7 @@ describe("ComboBox", () => {
it("should handle light variant", () => { it("should handle light variant", () => {
render(ComboBox, { props: { light: true } }); render(ComboBox, { props: { light: true } });
expect(screen.getByRole("textbox")).toHaveClass("bx--text-input--light"); expect(getInput()).toHaveClass("bx--text-input--light");
}); });
test.each([ test.each([
@ -195,7 +194,7 @@ describe("ComboBox", () => {
it("should handle filtering items", async () => { it("should handle filtering items", async () => {
render(ComboBox); render(ComboBox);
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
await user.type(input, "em"); await user.type(input, "em");
@ -229,21 +228,21 @@ describe("ComboBox", () => {
expect(consoleLog).not.toBeCalled(); expect(consoleLog).not.toBeCalled();
await user.click(getClearButton()); await user.click(getClearButton());
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
expect(consoleLog).toHaveBeenCalledWith("clear", "clear"); expect(consoleLog).toHaveBeenCalledWith("clear", "clear");
}); });
it("should handle disabled items", async () => { it("should handle disabled items", async () => {
render(ComboBoxCustom); render(ComboBoxCustom);
await user.click(screen.getByRole("textbox")); await user.click(getInput());
const disabledOption = screen.getByText(/Fax/).closest('[role="option"]'); const disabledOption = screen.getByText(/Fax/).closest('[role="option"]');
assert(disabledOption); assert(disabledOption);
expect(disabledOption).toHaveAttribute("disabled", "true"); expect(disabledOption).toHaveAttribute("disabled", "true");
expect(disabledOption).toHaveAttribute("aria-disabled", "true"); expect(disabledOption).toHaveAttribute("aria-disabled", "true");
await user.click(disabledOption); await user.click(disabledOption);
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
// Dropdown remains open // Dropdown remains open
const dropdown = screen.getAllByRole("listbox")[1]; const dropdown = screen.getAllByRole("listbox")[1];
@ -253,7 +252,7 @@ describe("ComboBox", () => {
it("should handle custom item display", async () => { it("should handle custom item display", async () => {
render(ComboBoxCustom); render(ComboBoxCustom);
await user.click(screen.getByRole("textbox")); await user.click(getInput());
const options = screen.getAllByRole("option"); const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Item Slack"); expect(options[0]).toHaveTextContent("Item Slack");
@ -272,19 +271,18 @@ describe("ComboBox", () => {
render(ComboBoxCustom, { props: { selectedId: "1" } }); render(ComboBoxCustom, { props: { selectedId: "1" } });
await user.click(getClearButton()); await user.click(getClearButton());
expect(getInput()).toHaveValue("");
const input = screen.getByRole("textbox");
expect(input).toHaveValue("");
}); });
it("should programmatically clear selection", async () => { it("should programmatically clear selection", async () => {
render(ComboBoxCustom, { props: { selectedId: "1" } }); render(ComboBoxCustom, { props: { selectedId: "1" } });
const textbox = screen.getByRole("textbox"); const input = getInput();
expect(textbox).toHaveValue("Email"); expect(input).toHaveValue("Email");
await user.click(screen.getByText("Clear")); await user.click(screen.getByText("Clear"));
expect(textbox).toHaveValue(""); expect(input).toHaveValue("");
expect(textbox).toHaveFocus(); expect(input).toHaveFocus();
}); });
it("should not re-focus textbox if clearOptions.focus is false", async () => { it("should not re-focus textbox if clearOptions.focus is false", async () => {
@ -295,19 +293,20 @@ describe("ComboBox", () => {
}, },
}); });
const textbox = screen.getByRole("textbox"); const input = getInput();
expect(textbox).toHaveValue("Email"); expect(input).toHaveValue("Email");
await user.click(screen.getByText("Clear")); await user.click(screen.getByText("Clear"));
expect(textbox).toHaveValue(""); expect(input).toHaveValue("");
expect(textbox).not.toHaveFocus(); expect(input).not.toHaveFocus();
}); });
it("should close menu on Escape key", async () => { it("should close menu on Escape key", async () => {
render(ComboBox); render(ComboBox);
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
const dropdown = screen.getAllByRole("listbox")[1]; const dropdown = screen.getAllByRole("listbox")[1];
@ -315,8 +314,8 @@ describe("ComboBox", () => {
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(dropdown).not.toBeVisible(); expect(dropdown).not.toBeVisible();
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
expect(screen.getByRole("textbox")).toHaveFocus(); expect(getInput()).toHaveFocus();
}); });
it("should close menu and clear selection on Escape key", async () => { it("should close menu and clear selection on Escape key", async () => {
@ -327,9 +326,9 @@ describe("ComboBox", () => {
}, },
}); });
expect(screen.getByRole("textbox")).toHaveValue("Email"); expect(getInput()).toHaveValue("Email");
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
const dropdown = screen.getAllByRole("listbox")[1]; const dropdown = screen.getAllByRole("listbox")[1];
@ -337,8 +336,8 @@ describe("ComboBox", () => {
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(dropdown).not.toBeVisible(); expect(dropdown).not.toBeVisible();
expect(screen.getByRole("textbox")).toHaveValue(""); expect(getInput()).toHaveValue("");
expect(screen.getByRole("textbox")).toHaveFocus(); expect(getInput()).toHaveFocus();
}); });
it("should use custom shouldFilterItem function", async () => { it("should use custom shouldFilterItem function", async () => {
@ -353,7 +352,7 @@ describe("ComboBox", () => {
item.text.startsWith(value), item.text.startsWith(value),
}, },
}); });
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
await user.type(input, "B"); await user.type(input, "B");
const options = screen.getAllByRole("option"); const options = screen.getAllByRole("option");
@ -371,7 +370,7 @@ describe("ComboBox", () => {
itemToString: (item: { text: string }) => `Item ${item.text}`, itemToString: (item: { text: string }) => `Item ${item.text}`,
}, },
}); });
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
const options = screen.getAllByRole("option"); const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Item Apple"); expect(options[0]).toHaveTextContent("Item Apple");
@ -395,7 +394,7 @@ describe("ComboBox", () => {
], ],
}, },
}); });
const input = screen.getByRole("textbox"); const input = getInput();
await user.click(input); await user.click(input);
await user.keyboard("{ArrowDown}"); // should highlight A await user.keyboard("{ArrowDown}"); // should highlight A
await user.keyboard("{ArrowDown}"); // should skip B and highlight C await user.keyboard("{ArrowDown}"); // should skip B and highlight C
@ -417,7 +416,7 @@ describe("ComboBox", () => {
render(ComboBox); render(ComboBox);
await user.keyboard("{Tab}"); await user.keyboard("{Tab}");
expect(screen.getByRole("textbox")).toHaveFocus(); expect(getInput()).toHaveFocus();
const dropdown = screen.queryAllByRole("listbox")[1]; const dropdown = screen.queryAllByRole("listbox")[1];
expect(dropdown).toBeUndefined(); expect(dropdown).toBeUndefined();