fix(multi-select): address a11y issues

#2172
This commit is contained in:
Eric Liu 2025-08-17 13:40:22 -07:00
commit 28a895e3cc
2 changed files with 141 additions and 135 deletions

View file

@ -347,63 +347,7 @@
class="bx--list-box__invalid-icon bx--list-box__invalid-icon--warning" class="bx--list-box__invalid-icon bx--list-box__invalid-icon--warning"
/> />
{/if} {/if}
<ListBoxField <div class:bx--list-box__field--wrapper={true}>
role="button"
tabindex="0"
aria-expanded={open}
on:click={() => {
if (disabled) return;
if (filterable) {
open = true;
inputRef.focus();
} else {
open = !open;
}
}}
on:keydown={(e) => {
if (filterable) {
return;
}
const key = e.key;
if ([" ", "ArrowUp", "ArrowDown"].includes(key)) {
e.preventDefault();
}
if (key === " ") {
open = !open;
} else if (key === "Tab") {
if (selectionRef && checked.length > 0) {
selectionRef.focus();
} else {
open = false;
}
} else if (key === "ArrowDown") {
change(1);
} else if (key === "ArrowUp") {
change(-1);
} else if (key === "Enter") {
if (highlightedIndex > -1) {
sortedItems = sortedItems.map((item, i) => {
if (i !== highlightedIndex) return item;
return { ...item, checked: !item.checked };
});
}
} else if (key === "Escape") {
open = false;
}
}}
on:focus={() => {
if (filterable) {
open = true;
if (inputRef) inputRef.focus();
}
}}
on:blur={(e) => {
if (!filterable) dispatch("blur", e);
}}
{id}
{disabled}
{translateWithId}
>
{#if checked.length > 0} {#if checked.length > 0}
<ListBoxSelection <ListBoxSelection
selectionCount={checked.length} selectionCount={checked.length}
@ -419,85 +363,143 @@
{disabled} {disabled}
/> />
{/if} {/if}
{#if filterable} <ListBoxField
<input role="button"
bind:this={inputRef} tabindex="0"
bind:value aria-expanded={open}
{...$$restProps} on:click={() => {
role="combobox" if (disabled) return;
tabindex="0" if (filterable) {
autocomplete="off" open = true;
aria-autocomplete="list" inputRef.focus();
aria-expanded={open} } else {
aria-activedescendant={highlightedId} open = !open;
aria-disabled={disabled} }
aria-controls={menuId} }}
class:bx--text-input={true} on:keydown={(e) => {
class:bx--text-input--empty={value === ""} if (filterable) {
class:bx--text-input--light={light} return;
on:keydown }
on:keydown|stopPropagation={({ key }) => { const key = e.key;
if (key === "Enter") { if ([" ", "ArrowUp", "ArrowDown"].includes(key)) {
if (highlightedId) { e.preventDefault();
const filteredItemIndex = sortedItems.findIndex( }
(item) => item.id === highlightedId, if (key === " ") {
); open = !open;
sortedItems = sortedItems.map((item, i) => { } else if (key === "Tab") {
if (i !== filteredItemIndex) return item; if (selectionRef && checked.length > 0) {
return { ...item, checked: !item.checked }; selectionRef.focus();
}); } else {
}
} else if (key === "Tab") {
open = false; open = false;
} else if (key === "ArrowDown") {
change(1);
} else if (key === "ArrowUp") {
change(-1);
} else if (key === "Escape") {
open = false;
} else if (key === " ") {
if (!open) open = true;
} }
}} } else if (key === "ArrowDown") {
on:input change(1);
on:keyup } else if (key === "ArrowUp") {
on:focus change(-1);
on:blur } else if (key === "Enter") {
on:paste if (highlightedIndex > -1) {
{disabled} sortedItems = sortedItems.map((item, i) => {
{placeholder} if (i !== highlightedIndex) return item;
{id} return { ...item, checked: !item.checked };
{name} });
/> }
{#if invalid} } else if (key === "Escape") {
<WarningFilled class="bx--list-box__invalid-icon" /> open = false;
{/if} }
{#if value} }}
<ListBoxSelection on:focus={() => {
on:clear={() => { if (filterable) {
value = ""; open = true;
open = false; if (inputRef) inputRef.focus();
}
}}
on:blur={(e) => {
if (!filterable) dispatch("blur", e);
}}
{id}
{disabled}
{translateWithId}
>
{#if filterable}
<input
bind:this={inputRef}
bind:value
{...$$restProps}
role="combobox"
tabindex="0"
autocomplete="off"
aria-autocomplete="list"
aria-expanded={open}
aria-activedescendant={highlightedId}
aria-disabled={disabled}
aria-controls={menuId}
class:bx--text-input={true}
class:bx--text-input--empty={value === ""}
class:bx--text-input--light={light}
on:keydown
on:keydown|stopPropagation={({ key }) => {
if (key === "Enter") {
if (highlightedId) {
const filteredItemIndex = sortedItems.findIndex(
(item) => item.id === highlightedId,
);
sortedItems = sortedItems.map((item, i) => {
if (i !== filteredItemIndex) return item;
return { ...item, checked: !item.checked };
});
}
} else if (key === "Tab") {
open = false;
} else if (key === "ArrowDown") {
change(1);
} else if (key === "ArrowUp") {
change(-1);
} else if (key === "Escape") {
open = false;
} else if (key === " ") {
if (!open) open = true;
}
}} }}
translateWithId={translateWithIdSelection} on:input
on:keyup
on:focus
on:blur
on:paste
{disabled} {disabled}
{placeholder}
{id}
{name}
/>
{#if invalid}
<WarningFilled class="bx--list-box__invalid-icon" />
{/if}
{#if value}
<ListBoxSelection
on:clear={() => {
value = "";
open = false;
}}
translateWithId={translateWithIdSelection}
{disabled}
{open}
/>
{/if}
<ListBoxMenuIcon
style="pointer-events: {open ? 'auto' : 'none'}"
on:click={(e) => {
e.stopPropagation();
open = !open;
}}
{translateWithId}
{open} {open}
/> />
{/if} {/if}
<ListBoxMenuIcon {#if !filterable}
style="pointer-events: {open ? 'auto' : 'none'}" <span class:bx--list-box__label={true}>{label}</span>
on:click={(e) => { <ListBoxMenuIcon {open} {translateWithId} />
e.stopPropagation(); {/if}
open = !open; </ListBoxField>
}} </div>
{translateWithId}
{open}
/>
{/if}
{#if !filterable}
<span class:bx--list-box__label={true}>{label}</span>
<ListBoxMenuIcon {open} {translateWithId} />
{/if}
</ListBoxField>
<div style:display={open ? "block" : "none"}> <div style:display={open ? "block" : "none"}>
<ListBoxMenu aria-label={ariaLabel} {id} aria-multiselectable="true"> <ListBoxMenu aria-label={ariaLabel} {id} aria-multiselectable="true">
{#each filterable ? filteredItems : sortedItems as item, i (item.id)} {#each filterable ? filteredItems : sortedItems as item, i (item.id)}

View file

@ -465,20 +465,24 @@ describe("MultiSelect", () => {
selectedIds: ["0", "1"], selectedIds: ["0", "1"],
}, },
}); });
await user.click(screen.getAllByRole("button")[0]);
await openMenu();
const options = screen.getAllByRole("option"); const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "true"); expect(options[0]).toHaveAttribute("aria-selected", "true");
expect(options[1]).toHaveAttribute("aria-selected", "true"); expect(options[1]).toHaveAttribute("aria-selected", "true");
expect(options[2]).toHaveAttribute("aria-selected", "false"); expect(options[2]).toHaveAttribute("aria-selected", "false");
const clearButton = screen.getByRole("button", { name: /clear/i }); await closeMenu();
const clearButton = screen.getByRole("button", {
name: /clear all selected items/i,
});
await user.click(clearButton); await user.click(clearButton);
await user.click(screen.getByRole("button"));
expect(options[0]).toHaveAttribute("aria-selected", "false"); await openMenu();
expect(options[1]).toHaveAttribute("aria-selected", "false"); const updatedOptions = screen.getAllByRole("option");
expect(options[2]).toHaveAttribute("aria-selected", "false"); expect(updatedOptions[0]).toHaveAttribute("aria-selected", "false");
expect(updatedOptions[1]).toHaveAttribute("aria-selected", "false");
expect(updatedOptions[2]).toHaveAttribute("aria-selected", "false");
}); });
it("skips disabled items during keyboard navigation", async () => { it("skips disabled items during keyboard navigation", async () => {