@arbor-education/design-system.components 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/components/avatar/Avatar.d.ts +1 -1
- package/dist/components/avatar/Avatar.d.ts.map +1 -1
- package/dist/components/avatar/Avatar.js +1 -1
- package/dist/components/avatar/Avatar.js.map +1 -1
- package/dist/components/avatar/Avatar.stories.d.ts.map +1 -1
- package/dist/components/avatar/Avatar.stories.js +7 -0
- package/dist/components/avatar/Avatar.stories.js.map +1 -1
- package/dist/components/badge/Badge.d.ts +12 -0
- package/dist/components/badge/Badge.d.ts.map +1 -0
- package/dist/components/badge/Badge.js +6 -0
- package/dist/components/badge/Badge.js.map +1 -0
- package/dist/components/badge/Badge.stories.d.ts +10 -0
- package/dist/components/badge/Badge.stories.d.ts.map +1 -0
- package/dist/components/badge/Badge.stories.js +51 -0
- package/dist/components/badge/Badge.stories.js.map +1 -0
- package/dist/components/badge/Badge.test.d.ts +2 -0
- package/dist/components/badge/Badge.test.d.ts.map +1 -0
- package/dist/components/badge/Badge.test.js +23 -0
- package/dist/components/badge/Badge.test.js.map +1 -0
- package/dist/components/card/Card.js +1 -1
- package/dist/components/card/Card.js.map +1 -1
- package/dist/components/combobox/Combobox.d.ts +16 -0
- package/dist/components/combobox/Combobox.d.ts.map +1 -0
- package/dist/components/combobox/Combobox.js +195 -0
- package/dist/components/combobox/Combobox.js.map +1 -0
- package/dist/components/combobox/Combobox.stories.d.ts +24 -0
- package/dist/components/combobox/Combobox.stories.d.ts.map +1 -0
- package/dist/components/combobox/Combobox.stories.js +246 -0
- package/dist/components/combobox/Combobox.stories.js.map +1 -0
- package/dist/components/combobox/Combobox.test.d.ts +2 -0
- package/dist/components/combobox/Combobox.test.d.ts.map +1 -0
- package/dist/components/combobox/Combobox.test.js +798 -0
- package/dist/components/combobox/Combobox.test.js.map +1 -0
- package/dist/components/combobox/ComboboxButtonTrigger.d.ts +28 -0
- package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -0
- package/dist/components/combobox/ComboboxButtonTrigger.js +64 -0
- package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -0
- package/dist/components/combobox/ComboboxListbox.d.ts +44 -0
- package/dist/components/combobox/ComboboxListbox.d.ts.map +1 -0
- package/dist/components/combobox/ComboboxListbox.js +37 -0
- package/dist/components/combobox/ComboboxListbox.js.map +1 -0
- package/dist/components/combobox/ComboboxOptionRow.d.ts +23 -0
- package/dist/components/combobox/ComboboxOptionRow.d.ts.map +1 -0
- package/dist/components/combobox/ComboboxOptionRow.js +27 -0
- package/dist/components/combobox/ComboboxOptionRow.js.map +1 -0
- package/dist/components/combobox/ComboboxTrigger.d.ts +35 -0
- package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -0
- package/dist/components/combobox/ComboboxTrigger.js +15 -0
- package/dist/components/combobox/ComboboxTrigger.js.map +1 -0
- package/dist/components/combobox/buildListboxDisplayOptions.d.ts +3 -0
- package/dist/components/combobox/buildListboxDisplayOptions.d.ts.map +1 -0
- package/dist/components/combobox/buildListboxDisplayOptions.js +13 -0
- package/dist/components/combobox/buildListboxDisplayOptions.js.map +1 -0
- package/dist/components/combobox/buildListboxDisplayOptions.test.d.ts +2 -0
- package/dist/components/combobox/buildListboxDisplayOptions.test.d.ts.map +1 -0
- package/dist/components/combobox/buildListboxDisplayOptions.test.js +22 -0
- package/dist/components/combobox/buildListboxDisplayOptions.test.js.map +1 -0
- package/dist/components/combobox/comboboxKeyboardTypes.d.ts +41 -0
- package/dist/components/combobox/comboboxKeyboardTypes.d.ts.map +1 -0
- package/dist/components/combobox/comboboxKeyboardTypes.js +2 -0
- package/dist/components/combobox/comboboxKeyboardTypes.js.map +1 -0
- package/dist/components/combobox/highlightLabel.d.ts +10 -0
- package/dist/components/combobox/highlightLabel.d.ts.map +1 -0
- package/dist/components/combobox/highlightLabel.js +18 -0
- package/dist/components/combobox/highlightLabel.js.map +1 -0
- package/dist/components/combobox/normaliseComboboxQuery.d.ts +2 -0
- package/dist/components/combobox/normaliseComboboxQuery.d.ts.map +1 -0
- package/dist/components/combobox/normaliseComboboxQuery.js +2 -0
- package/dist/components/combobox/normaliseComboboxQuery.js.map +1 -0
- package/dist/components/combobox/types.d.ts +46 -0
- package/dist/components/combobox/types.d.ts.map +1 -0
- package/dist/components/combobox/types.js +2 -0
- package/dist/components/combobox/types.js.map +1 -0
- package/dist/components/combobox/useChipSelection.d.ts +11 -0
- package/dist/components/combobox/useChipSelection.d.ts.map +1 -0
- package/dist/components/combobox/useChipSelection.js +35 -0
- package/dist/components/combobox/useChipSelection.js.map +1 -0
- package/dist/components/combobox/useComboboxChipKeyboard.d.ts +3 -0
- package/dist/components/combobox/useComboboxChipKeyboard.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxChipKeyboard.js +103 -0
- package/dist/components/combobox/useComboboxChipKeyboard.js.map +1 -0
- package/dist/components/combobox/useComboboxChipKeyboard.test.d.ts +2 -0
- package/dist/components/combobox/useComboboxChipKeyboard.test.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxChipKeyboard.test.js +116 -0
- package/dist/components/combobox/useComboboxChipKeyboard.test.js.map +1 -0
- package/dist/components/combobox/useComboboxKeyboard.d.ts +4 -0
- package/dist/components/combobox/useComboboxKeyboard.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxKeyboard.js +68 -0
- package/dist/components/combobox/useComboboxKeyboard.js.map +1 -0
- package/dist/components/combobox/useComboboxListboxDom.d.ts +11 -0
- package/dist/components/combobox/useComboboxListboxDom.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxListboxDom.js +15 -0
- package/dist/components/combobox/useComboboxListboxDom.js.map +1 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.d.ts +3 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.js +143 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.js.map +1 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.test.d.ts +2 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.test.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.test.js +152 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.test.js.map +1 -0
- package/dist/components/combobox/useComboboxPopoverBehavior.d.ts +38 -0
- package/dist/components/combobox/useComboboxPopoverBehavior.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxPopoverBehavior.js +104 -0
- package/dist/components/combobox/useComboboxPopoverBehavior.js.map +1 -0
- package/dist/components/combobox/useComboboxState.d.ts +27 -0
- package/dist/components/combobox/useComboboxState.d.ts.map +1 -0
- package/dist/components/combobox/useComboboxState.js +122 -0
- package/dist/components/combobox/useComboboxState.js.map +1 -0
- package/dist/components/combobox/useElementWidth.d.ts +2 -0
- package/dist/components/combobox/useElementWidth.d.ts.map +1 -0
- package/dist/components/combobox/useElementWidth.js +31 -0
- package/dist/components/combobox/useElementWidth.js.map +1 -0
- package/dist/components/combobox/useVisibleChips.d.ts +21 -0
- package/dist/components/combobox/useVisibleChips.d.ts.map +1 -0
- package/dist/components/combobox/useVisibleChips.js +59 -0
- package/dist/components/combobox/useVisibleChips.js.map +1 -0
- package/dist/components/combobox/useVisibleChips.test.d.ts +2 -0
- package/dist/components/combobox/useVisibleChips.test.d.ts.map +1 -0
- package/dist/components/combobox/useVisibleChips.test.js +81 -0
- package/dist/components/combobox/useVisibleChips.test.js.map +1 -0
- package/dist/components/dot/Dot.d.ts +8 -0
- package/dist/components/dot/Dot.d.ts.map +1 -0
- package/dist/components/dot/Dot.js +6 -0
- package/dist/components/dot/Dot.js.map +1 -0
- package/dist/components/dot/Dot.stories.d.ts +15 -0
- package/dist/components/dot/Dot.stories.d.ts.map +1 -0
- package/dist/components/dot/Dot.stories.js +25 -0
- package/dist/components/dot/Dot.stories.js.map +1 -0
- package/dist/components/dot/Dot.test.d.ts +2 -0
- package/dist/components/dot/Dot.test.d.ts.map +1 -0
- package/dist/components/dot/Dot.test.js +19 -0
- package/dist/components/dot/Dot.test.js.map +1 -0
- package/dist/components/formField/FormField.d.ts +8 -4
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +7 -6
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts +1 -0
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +13 -1
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +10 -0
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/icon/allowedIcons.d.ts +1 -0
- package/dist/components/icon/allowedIcons.d.ts.map +1 -1
- package/dist/components/icon/allowedIcons.js +2 -1
- package/dist/components/icon/allowedIcons.js.map +1 -1
- package/dist/components/progress/Progress.stories.d.ts +49 -49
- package/dist/components/singleUser/SingleUser.d.ts +15 -0
- package/dist/components/singleUser/SingleUser.d.ts.map +1 -0
- package/dist/components/singleUser/SingleUser.js +9 -0
- package/dist/components/singleUser/SingleUser.js.map +1 -0
- package/dist/components/singleUser/SingleUser.stories.d.ts +11 -0
- package/dist/components/singleUser/SingleUser.stories.d.ts.map +1 -0
- package/dist/components/singleUser/SingleUser.stories.js +52 -0
- package/dist/components/singleUser/SingleUser.stories.js.map +1 -0
- package/dist/components/singleUser/SingleUser.test.d.ts +2 -0
- package/dist/components/singleUser/SingleUser.test.d.ts.map +1 -0
- package/dist/components/singleUser/SingleUser.test.js +30 -0
- package/dist/components/singleUser/SingleUser.test.js.map +1 -0
- package/dist/components/tabs/TabsItem.stories.d.ts +2 -2
- package/dist/components/tag/Tag.d.ts +9 -6
- package/dist/components/tag/Tag.d.ts.map +1 -1
- package/dist/components/tag/Tag.js +8 -2
- package/dist/components/tag/Tag.js.map +1 -1
- package/dist/components/tag/Tag.stories.d.ts +11 -6
- package/dist/components/tag/Tag.stories.d.ts.map +1 -1
- package/dist/components/tag/Tag.stories.js +68 -4
- package/dist/components/tag/Tag.stories.js.map +1 -1
- package/dist/components/tag/Tag.test.js +86 -50
- package/dist/components/tag/Tag.test.js.map +1 -1
- package/dist/components/toggle/Toggle.d.ts +3 -0
- package/dist/components/toggle/Toggle.d.ts.map +1 -0
- package/dist/components/toggle/Toggle.js +8 -0
- package/dist/components/toggle/Toggle.js.map +1 -0
- package/dist/components/toggle/Toggle.stories.d.ts +97 -0
- package/dist/components/toggle/Toggle.stories.d.ts.map +1 -0
- package/dist/components/toggle/Toggle.stories.js +186 -0
- package/dist/components/toggle/Toggle.stories.js.map +1 -0
- package/dist/components/toggle/Toggle.test.d.ts +2 -0
- package/dist/components/toggle/Toggle.test.d.ts.map +1 -0
- package/dist/components/toggle/Toggle.test.js +58 -0
- package/dist/components/toggle/Toggle.test.js.map +1 -0
- package/dist/index.css +656 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +34 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -25
- package/dist/index.js.map +1 -1
- package/dist/mocks/comboboxStoryOptions.d.ts +5 -0
- package/dist/mocks/comboboxStoryOptions.d.ts.map +1 -0
- package/dist/mocks/comboboxStoryOptions.js +22 -0
- package/dist/mocks/comboboxStoryOptions.js.map +1 -0
- package/dist/utils/isSelectAllChord.d.ts +5 -0
- package/dist/utils/isSelectAllChord.d.ts.map +1 -0
- package/dist/utils/isSelectAllChord.js +7 -0
- package/dist/utils/isSelectAllChord.js.map +1 -0
- package/dist/utils/isSelectAllChord.test.d.ts +2 -0
- package/dist/utils/isSelectAllChord.test.d.ts.map +1 -0
- package/dist/utils/isSelectAllChord.test.js +19 -0
- package/dist/utils/isSelectAllChord.test.js.map +1 -0
- package/dist/utils/nextCircularIndex.d.ts +3 -0
- package/dist/utils/nextCircularIndex.d.ts.map +1 -0
- package/dist/utils/nextCircularIndex.js +10 -0
- package/dist/utils/nextCircularIndex.js.map +1 -0
- package/dist/utils/nextCircularIndex.test.d.ts +2 -0
- package/dist/utils/nextCircularIndex.test.d.ts.map +1 -0
- package/dist/utils/nextCircularIndex.test.js +23 -0
- package/dist/utils/nextCircularIndex.test.js.map +1 -0
- package/dist/utils/scrollElementIntoViewById.d.ts +2 -0
- package/dist/utils/scrollElementIntoViewById.d.ts.map +1 -0
- package/dist/utils/scrollElementIntoViewById.js +16 -0
- package/dist/utils/scrollElementIntoViewById.js.map +1 -0
- package/dist/utils/scrollElementIntoViewById.test.d.ts +2 -0
- package/dist/utils/scrollElementIntoViewById.test.d.ts.map +1 -0
- package/dist/utils/scrollElementIntoViewById.test.js +31 -0
- package/dist/utils/scrollElementIntoViewById.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/avatar/Avatar.stories.tsx +8 -0
- package/src/components/avatar/Avatar.tsx +3 -3
- package/src/components/badge/Badge.stories.tsx +74 -0
- package/src/components/badge/Badge.test.tsx +28 -0
- package/src/components/badge/Badge.tsx +35 -0
- package/src/components/badge/badge.scss +86 -0
- package/src/components/card/Card.tsx +1 -1
- package/src/components/combobox/Combobox.stories.tsx +340 -0
- package/src/components/combobox/Combobox.test.tsx +1160 -0
- package/src/components/combobox/Combobox.tsx +434 -0
- package/src/components/combobox/ComboboxButtonTrigger.tsx +195 -0
- package/src/components/combobox/ComboboxListbox.tsx +224 -0
- package/src/components/combobox/ComboboxOptionRow.tsx +128 -0
- package/src/components/combobox/ComboboxTrigger.tsx +134 -0
- package/src/components/combobox/buildListboxDisplayOptions.test.ts +24 -0
- package/src/components/combobox/buildListboxDisplayOptions.ts +12 -0
- package/src/components/combobox/combobox.scss +390 -0
- package/src/components/combobox/comboboxKeyboardTypes.ts +45 -0
- package/src/components/combobox/highlightLabel.tsx +42 -0
- package/src/components/combobox/normaliseComboboxQuery.ts +1 -0
- package/src/components/combobox/types.ts +53 -0
- package/src/components/combobox/useChipSelection.ts +53 -0
- package/src/components/combobox/useComboboxChipKeyboard.test.tsx +141 -0
- package/src/components/combobox/useComboboxChipKeyboard.ts +121 -0
- package/src/components/combobox/useComboboxKeyboard.ts +108 -0
- package/src/components/combobox/useComboboxListboxDom.ts +36 -0
- package/src/components/combobox/useComboboxListboxKeyboard.test.tsx +186 -0
- package/src/components/combobox/useComboboxListboxKeyboard.ts +172 -0
- package/src/components/combobox/useComboboxPopoverBehavior.ts +179 -0
- package/src/components/combobox/useComboboxState.ts +232 -0
- package/src/components/combobox/useElementWidth.ts +40 -0
- package/src/components/combobox/useVisibleChips.test.tsx +91 -0
- package/src/components/combobox/useVisibleChips.ts +100 -0
- package/src/components/dot/Dot.stories.tsx +41 -0
- package/src/components/dot/Dot.test.tsx +21 -0
- package/src/components/dot/Dot.tsx +18 -0
- package/src/components/dot/dot.scss +35 -0
- package/src/components/formField/FormField.stories.tsx +30 -1
- package/src/components/formField/FormField.test.tsx +20 -0
- package/src/components/formField/FormField.tsx +11 -5
- package/src/components/formField/inputs/number/numberInput.scss +12 -4
- package/src/components/icon/allowedIcons.tsx +2 -0
- package/src/components/pill/pill.scss +4 -6
- package/src/components/singleUser/SingleUser.stories.tsx +63 -0
- package/src/components/singleUser/SingleUser.test.tsx +61 -0
- package/src/components/singleUser/SingleUser.tsx +45 -0
- package/src/components/singleUser/singleUser.scss +14 -0
- package/src/components/tag/Tag.stories.tsx +88 -6
- package/src/components/tag/Tag.test.tsx +110 -44
- package/src/components/tag/Tag.tsx +38 -14
- package/src/components/tag/tag.scss +45 -30
- package/src/components/toggle/Toggle.stories.tsx +239 -0
- package/src/components/toggle/Toggle.test.tsx +66 -0
- package/src/components/toggle/Toggle.tsx +12 -0
- package/src/components/toggle/toggle.scss +126 -0
- package/src/index.scss +5 -0
- package/src/index.ts +47 -31
- package/src/mocks/comboboxStoryOptions.ts +25 -0
- package/src/tokens.scss +33 -4
- package/src/utils/isSelectAllChord.test.ts +24 -0
- package/src/utils/isSelectAllChord.ts +8 -0
- package/src/utils/nextCircularIndex.test.ts +26 -0
- package/src/utils/nextCircularIndex.ts +15 -0
- package/src/utils/scrollElementIntoViewById.test.ts +38 -0
- package/src/utils/scrollElementIntoViewById.ts +20 -0
- package/tokens/json/Arbor.json +3828 -3704
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
5
|
+
import { Combobox } from './Combobox';
|
|
6
|
+
import type { ComboboxOption } from './types';
|
|
7
|
+
|
|
8
|
+
const people: ComboboxOption[] = [
|
|
9
|
+
{ value: 'alice', label: 'Alice Johnson', iconName: 'user' },
|
|
10
|
+
{ value: 'bob', label: 'Bob Smith', iconName: 'user' },
|
|
11
|
+
{ value: 'charlie', label: 'Charlie Brown', iconName: 'user' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe('Combobox', () => {
|
|
15
|
+
const openWithChevron = async () => {
|
|
16
|
+
if (screen.queryByRole('listbox')) return;
|
|
17
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open suggestions' }));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test('renders with placeholder when nothing is selected', () => {
|
|
21
|
+
render(<Combobox options={people} placeholder="Search people..." />);
|
|
22
|
+
expect(screen.getByPlaceholderText('Search people...')).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('has correct ARIA attributes on the input', () => {
|
|
26
|
+
render(<Combobox options={people} aria-label="People" />);
|
|
27
|
+
const input = screen.getByRole('combobox');
|
|
28
|
+
expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
|
29
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
30
|
+
expect(input).toHaveAttribute('aria-label', 'People');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('aria-controls always references the listbox id', () => {
|
|
34
|
+
render(<Combobox options={people} id="test-cb" />);
|
|
35
|
+
const input = screen.getByRole('combobox');
|
|
36
|
+
expect(input).toHaveAttribute('aria-controls', 'test-cb-listbox');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('opens listbox on focus by default when options are available', async () => {
|
|
40
|
+
render(<Combobox options={people} />);
|
|
41
|
+
const input = screen.getByRole('combobox');
|
|
42
|
+
fireEvent.focus(input);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('does not open listbox on focus when dropdownOnFocus is false', () => {
|
|
48
|
+
render(<Combobox options={people} dropdownOnFocus={false} />);
|
|
49
|
+
const input = screen.getByRole('combobox');
|
|
50
|
+
fireEvent.focus(input);
|
|
51
|
+
|
|
52
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('does not force an empty panel on focus when dropdownOnFocus is true but no items are available', () => {
|
|
56
|
+
render(<Combobox options={[]} dropdownOnFocus />);
|
|
57
|
+
const input = screen.getByRole('combobox');
|
|
58
|
+
fireEvent.focus(input);
|
|
59
|
+
|
|
60
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('showDropdownTrigger=false hides the chevron button', () => {
|
|
64
|
+
render(<Combobox options={people} showDropdownTrigger={false} />);
|
|
65
|
+
|
|
66
|
+
expect(screen.queryByRole('button', { name: /suggestions/i })).not.toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('showDropdownTrigger=false still allows focus opening', () => {
|
|
70
|
+
render(<Combobox options={people} showDropdownTrigger={false} />);
|
|
71
|
+
const input = screen.getByRole('combobox');
|
|
72
|
+
fireEvent.focus(input);
|
|
73
|
+
|
|
74
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('clicking the input opens reliably when dropdownOnFocus is enabled', async () => {
|
|
78
|
+
render(<Combobox options={people} />);
|
|
79
|
+
const input = screen.getByRole('combobox');
|
|
80
|
+
|
|
81
|
+
await userEvent.click(input);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('clicking the trigger reopens even when the input is already focused', async () => {
|
|
87
|
+
render(<Combobox options={people} />);
|
|
88
|
+
const input = screen.getByRole('combobox');
|
|
89
|
+
const trigger = input.closest('.ds-combobox__trigger');
|
|
90
|
+
|
|
91
|
+
fireEvent.focus(input);
|
|
92
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
93
|
+
|
|
94
|
+
fireEvent.keyDown(input, { key: 'Escape' });
|
|
95
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
96
|
+
|
|
97
|
+
expect(trigger).toBeTruthy();
|
|
98
|
+
await userEvent.click(trigger as HTMLElement);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('clicking the input while open does not collapse the dropdown', async () => {
|
|
104
|
+
render(<Combobox options={people} />);
|
|
105
|
+
const input = screen.getByRole('combobox');
|
|
106
|
+
|
|
107
|
+
fireEvent.focus(input);
|
|
108
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
109
|
+
|
|
110
|
+
await userEvent.click(input);
|
|
111
|
+
|
|
112
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('opens listbox on chevron click and shows options', async () => {
|
|
116
|
+
render(<Combobox options={people} />);
|
|
117
|
+
await openWithChevron();
|
|
118
|
+
|
|
119
|
+
const listbox = screen.getByRole('listbox');
|
|
120
|
+
expect(listbox).toBeInTheDocument();
|
|
121
|
+
const options = within(listbox).getAllByRole('option');
|
|
122
|
+
expect(options).toHaveLength(3);
|
|
123
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'true');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('button trigger variant has no inline input before open', () => {
|
|
127
|
+
render(<Combobox options={people} triggerVariant="button" placeholder="Students" />);
|
|
128
|
+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
|
129
|
+
expect(screen.getByRole('button', { name: 'Open suggestions' })).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('button trigger variant opens on Enter and renders search input in dropdown', async () => {
|
|
133
|
+
render(<Combobox options={people} triggerVariant="button" placeholder="Students" />);
|
|
134
|
+
const trigger = screen.getByRole('button', { name: /students/i });
|
|
135
|
+
fireEvent.keyDown(trigger, { key: 'Enter' });
|
|
136
|
+
|
|
137
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('button trigger variant dropdown search input receives focus when clicked', async () => {
|
|
142
|
+
render(<Combobox options={people} triggerVariant="button" placeholder="Students" />);
|
|
143
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open suggestions' }));
|
|
144
|
+
|
|
145
|
+
const search = screen.getByRole('combobox');
|
|
146
|
+
await userEvent.click(search);
|
|
147
|
+
expect(search).toHaveFocus();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('button trigger single-select does not show selection count badge', () => {
|
|
151
|
+
render(
|
|
152
|
+
<Combobox
|
|
153
|
+
options={people}
|
|
154
|
+
triggerVariant="button"
|
|
155
|
+
defaultValue={['alice']}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
expect(screen.queryByLabelText(/selected item/i)).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('button trigger multi-select shows salmon count badge when multiple chips', () => {
|
|
162
|
+
render(
|
|
163
|
+
<Combobox
|
|
164
|
+
options={people}
|
|
165
|
+
triggerVariant="button"
|
|
166
|
+
multiple
|
|
167
|
+
defaultValue={['alice', 'bob']}
|
|
168
|
+
/>,
|
|
169
|
+
);
|
|
170
|
+
const badge = screen.getByLabelText(/2 selected items/i);
|
|
171
|
+
expect(badge).toHaveClass('ds-badge--salmon');
|
|
172
|
+
expect(badge).toHaveTextContent('2');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('button trigger does not show ellipsis when all selected chips fit', () => {
|
|
176
|
+
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect(this: HTMLElement) {
|
|
177
|
+
let width = 0;
|
|
178
|
+
|
|
179
|
+
if (this.classList.contains('ds-combobox__button-content')) width = 420;
|
|
180
|
+
else if (this.classList.contains('ds-combobox__measure-chip')) width = 80;
|
|
181
|
+
else if (this.classList.contains('ds-combobox__button-ellipsis')) width = 11.6;
|
|
182
|
+
else if (this.classList.contains('ds-badge')) width = 24.2;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
width,
|
|
186
|
+
height: 0,
|
|
187
|
+
top: 0,
|
|
188
|
+
left: 0,
|
|
189
|
+
right: width,
|
|
190
|
+
bottom: 0,
|
|
191
|
+
x: 0,
|
|
192
|
+
y: 0,
|
|
193
|
+
toJSON() {
|
|
194
|
+
return {};
|
|
195
|
+
},
|
|
196
|
+
} as DOMRect;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
render(
|
|
201
|
+
<Combobox
|
|
202
|
+
options={people}
|
|
203
|
+
triggerVariant="button"
|
|
204
|
+
multiple
|
|
205
|
+
defaultValue={['alice', 'bob', 'charlie']}
|
|
206
|
+
/>,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(document.querySelector('.ds-combobox__button-tags-viewport .ds-combobox__button-ellipsis')).not.toBeInTheDocument();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
rectSpy.mockRestore();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('button trigger truncates tags and shows ellipsis when space is constrained', () => {
|
|
217
|
+
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect(this: HTMLElement) {
|
|
218
|
+
let width = 0;
|
|
219
|
+
|
|
220
|
+
if (this.classList.contains('ds-combobox__button-content')) width = 220;
|
|
221
|
+
else if (this.classList.contains('ds-combobox__measure-chip')) width = 120.4;
|
|
222
|
+
else if (this.classList.contains('ds-combobox__button-ellipsis')) width = 11.6;
|
|
223
|
+
else if (this.classList.contains('ds-badge')) width = 24.2;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
width,
|
|
227
|
+
height: 0,
|
|
228
|
+
top: 0,
|
|
229
|
+
left: 0,
|
|
230
|
+
right: width,
|
|
231
|
+
bottom: 0,
|
|
232
|
+
x: 0,
|
|
233
|
+
y: 0,
|
|
234
|
+
toJSON() {
|
|
235
|
+
return {};
|
|
236
|
+
},
|
|
237
|
+
} as DOMRect;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const longOptions: ComboboxOption[] = [
|
|
241
|
+
{ value: 'a', label: 'Alexandria Montgomery' },
|
|
242
|
+
{ value: 'b', label: 'Benjamin Rutherford' },
|
|
243
|
+
{ value: 'c', label: 'Charlotte Kensington' },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const { container } = render(
|
|
247
|
+
<div style={{ width: 280 }}>
|
|
248
|
+
<Combobox
|
|
249
|
+
options={longOptions}
|
|
250
|
+
triggerVariant="button"
|
|
251
|
+
multiple
|
|
252
|
+
defaultValue={['a', 'b', 'c']}
|
|
253
|
+
/>
|
|
254
|
+
</div>,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
expect(container.querySelector('.ds-combobox__button-tags-viewport .ds-combobox__button-ellipsis')).toBeInTheDocument();
|
|
259
|
+
expect(screen.getByLabelText(/3 selected items/i)).toBeInTheDocument();
|
|
260
|
+
const visibleTags = container.querySelectorAll('.ds-combobox__button-tags-viewport .ds-tag');
|
|
261
|
+
expect(visibleTags.length).toBeLessThan(3);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
rectSpy.mockRestore();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('selected options remain in the listbox without duplication', async () => {
|
|
269
|
+
render(
|
|
270
|
+
<Combobox
|
|
271
|
+
options={people}
|
|
272
|
+
triggerVariant="button"
|
|
273
|
+
multiple
|
|
274
|
+
defaultValue={['alice', 'bob']}
|
|
275
|
+
/>,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open suggestions' }));
|
|
279
|
+
const listbox = screen.getByRole('listbox');
|
|
280
|
+
expect(within(listbox).getAllByRole('option')).toHaveLength(people.length);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('when dropdownOnFocus is false, clearing the search hides the listbox again', async () => {
|
|
284
|
+
render(<Combobox options={people} dropdownOnFocus={false} />);
|
|
285
|
+
const input = screen.getByRole('combobox');
|
|
286
|
+
|
|
287
|
+
await userEvent.type(input, 'ali');
|
|
288
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
289
|
+
|
|
290
|
+
await userEvent.clear(input);
|
|
291
|
+
|
|
292
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('selecting an option in single-select mode adds a chip', async () => {
|
|
296
|
+
const onValueChange = vi.fn();
|
|
297
|
+
render(<Combobox options={people} onValueChange={onValueChange} />);
|
|
298
|
+
|
|
299
|
+
await openWithChevron();
|
|
300
|
+
await userEvent.click(screen.getByText('Alice Johnson'));
|
|
301
|
+
|
|
302
|
+
expect(onValueChange).toHaveBeenCalledWith(['alice']);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('single-select replaces the current chip on new selection', async () => {
|
|
306
|
+
const onValueChange = vi.fn();
|
|
307
|
+
render(<Combobox options={people} defaultValue={['alice']} onValueChange={onValueChange} />);
|
|
308
|
+
|
|
309
|
+
await openWithChevron();
|
|
310
|
+
await userEvent.click(screen.getByText('Bob Smith'));
|
|
311
|
+
|
|
312
|
+
expect(onValueChange).toHaveBeenCalledWith(['bob']);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('multi-select allows multiple chips', async () => {
|
|
316
|
+
const onValueChange = vi.fn();
|
|
317
|
+
render(<Combobox options={people} multiple onValueChange={onValueChange} />);
|
|
318
|
+
|
|
319
|
+
await openWithChevron();
|
|
320
|
+
await userEvent.click(screen.getByText('Alice Johnson'));
|
|
321
|
+
|
|
322
|
+
await openWithChevron();
|
|
323
|
+
await userEvent.click(screen.getByText('Bob Smith'));
|
|
324
|
+
|
|
325
|
+
expect(onValueChange).toHaveBeenLastCalledWith(['alice', 'bob']);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Ctrl+A selects all chips when input is focused', async () => {
|
|
329
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
330
|
+
const input = screen.getByRole('combobox');
|
|
331
|
+
input.focus();
|
|
332
|
+
|
|
333
|
+
await userEvent.keyboard('{Control>}a{/Control}');
|
|
334
|
+
|
|
335
|
+
const selectedTags = document.querySelectorAll('.ds-tag--selected');
|
|
336
|
+
expect(selectedTags).toHaveLength(2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('Cmd+A selects all chips in single-select mode', async () => {
|
|
340
|
+
render(<Combobox options={people} defaultValue={['alice']} />);
|
|
341
|
+
const input = screen.getByRole('combobox');
|
|
342
|
+
input.focus();
|
|
343
|
+
|
|
344
|
+
fireEvent.keyDown(input, { key: 'a', metaKey: true });
|
|
345
|
+
|
|
346
|
+
const selectedTags = document.querySelectorAll('.ds-tag--selected');
|
|
347
|
+
expect(selectedTags).toHaveLength(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('Backspace clears all selected chips after select-all', async () => {
|
|
351
|
+
const onValueChange = vi.fn();
|
|
352
|
+
render(
|
|
353
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
|
|
354
|
+
);
|
|
355
|
+
const input = screen.getByRole('combobox');
|
|
356
|
+
input.focus();
|
|
357
|
+
|
|
358
|
+
await userEvent.keyboard('{Control>}a{/Control}');
|
|
359
|
+
await userEvent.keyboard('{Backspace}');
|
|
360
|
+
|
|
361
|
+
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
362
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('Delete clears all selected chips after select-all', async () => {
|
|
366
|
+
const onValueChange = vi.fn();
|
|
367
|
+
render(
|
|
368
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
|
|
369
|
+
);
|
|
370
|
+
const input = screen.getByRole('combobox');
|
|
371
|
+
input.focus();
|
|
372
|
+
|
|
373
|
+
await userEvent.keyboard('{Control>}a{/Control}');
|
|
374
|
+
await userEvent.keyboard('{Delete}');
|
|
375
|
+
|
|
376
|
+
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
377
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('typing after chip select-all clears selected chip styling', async () => {
|
|
381
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
382
|
+
const input = screen.getByRole('combobox');
|
|
383
|
+
input.focus();
|
|
384
|
+
|
|
385
|
+
await userEvent.keyboard('{Control>}a{/Control}');
|
|
386
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(2);
|
|
387
|
+
|
|
388
|
+
await userEvent.type(input, 'c');
|
|
389
|
+
|
|
390
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('Ctrl+A with no chips keeps native input behavior and does not crash', async () => {
|
|
394
|
+
render(<Combobox options={people} />);
|
|
395
|
+
const input = screen.getByRole('combobox');
|
|
396
|
+
input.focus();
|
|
397
|
+
|
|
398
|
+
await userEvent.keyboard('{Control>}a{/Control}');
|
|
399
|
+
|
|
400
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('removing a chip calls onValueChange without that value', async () => {
|
|
404
|
+
const onValueChange = vi.fn();
|
|
405
|
+
render(
|
|
406
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const aliceTag = screen.getByText('Alice Johnson').closest('.ds-tag') as HTMLElement;
|
|
410
|
+
const removeBtn = within(aliceTag).getByRole('button', { name: 'Remove Alice Johnson' });
|
|
411
|
+
await userEvent.click(removeBtn);
|
|
412
|
+
|
|
413
|
+
expect(onValueChange).toHaveBeenCalledWith(['bob']);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('uses resolved tag labels in button trigger remove button labels', () => {
|
|
417
|
+
render(
|
|
418
|
+
<Combobox
|
|
419
|
+
options={[
|
|
420
|
+
{ value: 'alice', label: 'Alice Johnson', tagLabel: 'Alice J.', iconName: 'user' },
|
|
421
|
+
]}
|
|
422
|
+
triggerVariant="button"
|
|
423
|
+
multiple
|
|
424
|
+
defaultValue={['alice']}
|
|
425
|
+
/>,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
expect(screen.getByRole('button', { name: 'Remove Alice J.' })).toBeInTheDocument();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('typing filters options client-side', async () => {
|
|
432
|
+
render(<Combobox options={people} />);
|
|
433
|
+
const input = screen.getByRole('combobox');
|
|
434
|
+
|
|
435
|
+
await userEvent.type(input, 'Ali');
|
|
436
|
+
|
|
437
|
+
const options = screen.getAllByRole('option');
|
|
438
|
+
expect(options).toHaveLength(1);
|
|
439
|
+
expect(options[0]).toHaveTextContent('Alice Johnson');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('default searchType uses prefix matching rather than substring matching', async () => {
|
|
443
|
+
render(<Combobox options={people} />);
|
|
444
|
+
const input = screen.getByRole('combobox');
|
|
445
|
+
|
|
446
|
+
await userEvent.type(input, 'son');
|
|
447
|
+
|
|
448
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('substring searchType matches text anywhere in the label', async () => {
|
|
452
|
+
render(<Combobox options={people} searchType="substring" />);
|
|
453
|
+
const input = screen.getByRole('combobox');
|
|
454
|
+
|
|
455
|
+
await userEvent.type(input, 'son');
|
|
456
|
+
|
|
457
|
+
const options = screen.getAllByRole('option');
|
|
458
|
+
expect(options).toHaveLength(1);
|
|
459
|
+
expect(options[0]).toHaveTextContent('Alice Johnson');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('function-valued searchType can provide custom matching', async () => {
|
|
463
|
+
const initialsSearch = (option: ComboboxOption, query: string) =>
|
|
464
|
+
option.label
|
|
465
|
+
.toLowerCase()
|
|
466
|
+
.split(' ')
|
|
467
|
+
.map(part => part[0])
|
|
468
|
+
.join('')
|
|
469
|
+
.includes(query.toLowerCase());
|
|
470
|
+
|
|
471
|
+
render(<Combobox options={people} searchType={initialsSearch} />);
|
|
472
|
+
const input = screen.getByRole('combobox');
|
|
473
|
+
|
|
474
|
+
await userEvent.type(input, 'aj');
|
|
475
|
+
|
|
476
|
+
const options = screen.getAllByRole('option');
|
|
477
|
+
expect(options).toHaveLength(1);
|
|
478
|
+
expect(options[0]).toHaveTextContent('Alice Johnson');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('renderOption allows custom option row content', async () => {
|
|
482
|
+
render(
|
|
483
|
+
<Combobox
|
|
484
|
+
options={people}
|
|
485
|
+
renderOption={(option, selected) => (
|
|
486
|
+
<span>{selected ? `Selected: ${option.label}` : `Row: ${option.label}`}</span>
|
|
487
|
+
)}
|
|
488
|
+
/>,
|
|
489
|
+
);
|
|
490
|
+
const input = screen.getByRole('combobox');
|
|
491
|
+
|
|
492
|
+
await userEvent.click(input);
|
|
493
|
+
|
|
494
|
+
expect(screen.getByText('Row: Alice Johnson')).toBeInTheDocument();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('highlightStringMatches marks prefix matches when enabled', async () => {
|
|
498
|
+
render(<Combobox options={people} highlightStringMatches />);
|
|
499
|
+
const input = screen.getByRole('combobox');
|
|
500
|
+
|
|
501
|
+
await userEvent.type(input, 'Ali');
|
|
502
|
+
|
|
503
|
+
const option = screen.getByRole('option');
|
|
504
|
+
expect(within(option).getByText('Ali', { selector: 'mark' })).toBeInTheDocument();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('highlightStringMatches marks substring matches when enabled', async () => {
|
|
508
|
+
render(
|
|
509
|
+
<Combobox
|
|
510
|
+
options={people}
|
|
511
|
+
searchType="substring"
|
|
512
|
+
highlightStringMatches
|
|
513
|
+
/>,
|
|
514
|
+
);
|
|
515
|
+
const input = screen.getByRole('combobox');
|
|
516
|
+
|
|
517
|
+
await userEvent.type(input, 'son');
|
|
518
|
+
|
|
519
|
+
const option = screen.getByRole('option');
|
|
520
|
+
expect(within(option).getByText('son', { selector: 'mark' })).toBeInTheDocument();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('function-valued searchType does not apply built-in mark highlighting', async () => {
|
|
524
|
+
const initialsSearch = (option: ComboboxOption, query: string) =>
|
|
525
|
+
option.label
|
|
526
|
+
.toLowerCase()
|
|
527
|
+
.split(' ')
|
|
528
|
+
.map(part => part[0])
|
|
529
|
+
.join('')
|
|
530
|
+
.includes(query.toLowerCase());
|
|
531
|
+
|
|
532
|
+
render(
|
|
533
|
+
<Combobox
|
|
534
|
+
options={people}
|
|
535
|
+
searchType={initialsSearch}
|
|
536
|
+
highlightStringMatches
|
|
537
|
+
/>,
|
|
538
|
+
);
|
|
539
|
+
const input = screen.getByRole('combobox');
|
|
540
|
+
|
|
541
|
+
await userEvent.type(input, 'aj');
|
|
542
|
+
|
|
543
|
+
const option = screen.getByRole('option');
|
|
544
|
+
expect(option).toHaveTextContent('Alice Johnson');
|
|
545
|
+
expect(within(option).queryByText(/aj/i, { selector: 'mark' })).not.toBeInTheDocument();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('clears filter text and hides popup when no matches and allowCreate is false', async () => {
|
|
549
|
+
render(<Combobox options={people} />);
|
|
550
|
+
const input = screen.getByRole('combobox');
|
|
551
|
+
|
|
552
|
+
await userEvent.type(input, 'zzzzz');
|
|
553
|
+
|
|
554
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('keyboard: ArrowDown opens the listbox', async () => {
|
|
558
|
+
render(<Combobox options={people} />);
|
|
559
|
+
const input = screen.getByRole('combobox');
|
|
560
|
+
input.focus();
|
|
561
|
+
|
|
562
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
563
|
+
|
|
564
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('keyboard: Alt+ArrowDown opens the listbox', async () => {
|
|
568
|
+
render(<Combobox options={people} />);
|
|
569
|
+
const input = screen.getByRole('combobox');
|
|
570
|
+
input.focus();
|
|
571
|
+
|
|
572
|
+
await userEvent.keyboard('{Alt>}{ArrowDown}{/Alt}');
|
|
573
|
+
|
|
574
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test('keyboard: ArrowDown/ArrowUp moves highlight', async () => {
|
|
578
|
+
render(<Combobox options={people} />);
|
|
579
|
+
const input = screen.getByRole('combobox');
|
|
580
|
+
await openWithChevron();
|
|
581
|
+
|
|
582
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
583
|
+
const firstOption = screen.getAllByRole('option')[0]!;
|
|
584
|
+
expect(firstOption).toHaveClass('ds-combobox__option-row--highlighted');
|
|
585
|
+
expect(input).toHaveAttribute('aria-activedescendant', firstOption.id);
|
|
586
|
+
|
|
587
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
588
|
+
const secondOption = screen.getAllByRole('option')[1]!;
|
|
589
|
+
expect(secondOption).toHaveClass('ds-combobox__option-row--highlighted');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('keyboard: Enter selects the highlighted option', async () => {
|
|
593
|
+
const onValueChange = vi.fn();
|
|
594
|
+
render(<Combobox options={people} onValueChange={onValueChange} />);
|
|
595
|
+
await openWithChevron();
|
|
596
|
+
|
|
597
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
598
|
+
await userEvent.keyboard('{Enter}');
|
|
599
|
+
|
|
600
|
+
expect(onValueChange).toHaveBeenCalledWith(['alice']);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('keyboard: Escape closes the listbox', async () => {
|
|
604
|
+
render(<Combobox options={people} />);
|
|
605
|
+
await openWithChevron();
|
|
606
|
+
|
|
607
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
608
|
+
|
|
609
|
+
await userEvent.keyboard('{Escape}');
|
|
610
|
+
|
|
611
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('keyboard: Backspace on empty input removes last chip', async () => {
|
|
615
|
+
const onValueChange = vi.fn();
|
|
616
|
+
render(
|
|
617
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
|
|
618
|
+
);
|
|
619
|
+
const input = screen.getByRole('combobox');
|
|
620
|
+
input.focus();
|
|
621
|
+
|
|
622
|
+
await userEvent.keyboard('{Backspace}');
|
|
623
|
+
|
|
624
|
+
expect(onValueChange).toHaveBeenCalledWith(['alice']);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('allowCreate shows a create row when no exact match', async () => {
|
|
628
|
+
const onCreateNew = vi.fn();
|
|
629
|
+
render(<Combobox options={people} allowCreate onCreateNew={onCreateNew} />);
|
|
630
|
+
const input = screen.getByRole('combobox');
|
|
631
|
+
|
|
632
|
+
await userEvent.type(input, 'Zara');
|
|
633
|
+
|
|
634
|
+
const createRow = screen.getByText(/Create/);
|
|
635
|
+
expect(createRow).toBeInTheDocument();
|
|
636
|
+
|
|
637
|
+
await userEvent.click(createRow.closest('[role="option"]')!);
|
|
638
|
+
expect(onCreateNew).toHaveBeenCalledWith('Zara');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('create row has descriptive aria-label', async () => {
|
|
642
|
+
render(<Combobox options={people} allowCreate />);
|
|
643
|
+
const input = screen.getByRole('combobox');
|
|
644
|
+
|
|
645
|
+
await userEvent.type(input, 'Zara');
|
|
646
|
+
|
|
647
|
+
const createOption = screen.getByRole('option', { name: /Create new option: Zara/i });
|
|
648
|
+
expect(createOption).toBeInTheDocument();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('allowCreate: Enter creates and selects in multi mode', async () => {
|
|
652
|
+
const onCreateNew = vi.fn((name: string) => ({
|
|
653
|
+
value: name.toLowerCase(),
|
|
654
|
+
label: name,
|
|
655
|
+
}));
|
|
656
|
+
const onValueChange = vi.fn();
|
|
657
|
+
render(
|
|
658
|
+
<Combobox
|
|
659
|
+
options={people}
|
|
660
|
+
allowCreate
|
|
661
|
+
multiple
|
|
662
|
+
onCreateNew={onCreateNew}
|
|
663
|
+
onValueChange={onValueChange}
|
|
664
|
+
/>,
|
|
665
|
+
);
|
|
666
|
+
const input = screen.getByRole('combobox');
|
|
667
|
+
|
|
668
|
+
await userEvent.type(input, 'NewPerson');
|
|
669
|
+
await userEvent.keyboard('{Enter}');
|
|
670
|
+
|
|
671
|
+
expect(onCreateNew).toHaveBeenCalledWith('NewPerson');
|
|
672
|
+
expect(onValueChange).toHaveBeenCalledWith(['newperson']);
|
|
673
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('allowCreate creates and replaces current selection in single mode', async () => {
|
|
677
|
+
const onCreateNew = vi.fn((name: string) => ({
|
|
678
|
+
value: `new-${name.toLowerCase()}`,
|
|
679
|
+
label: name,
|
|
680
|
+
}));
|
|
681
|
+
const onValueChange = vi.fn();
|
|
682
|
+
render(
|
|
683
|
+
<Combobox
|
|
684
|
+
options={people}
|
|
685
|
+
allowCreate
|
|
686
|
+
defaultValue={['alice']}
|
|
687
|
+
onCreateNew={onCreateNew}
|
|
688
|
+
onValueChange={onValueChange}
|
|
689
|
+
/>,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const input = screen.getByRole('combobox');
|
|
693
|
+
await userEvent.type(input, 'Taylor');
|
|
694
|
+
await userEvent.keyboard('{Enter}');
|
|
695
|
+
|
|
696
|
+
expect(onCreateNew).toHaveBeenCalledWith('Taylor');
|
|
697
|
+
expect(onValueChange).toHaveBeenCalledWith(['new-taylor']);
|
|
698
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('allowCreate duplicate prevention selects existing option', async () => {
|
|
702
|
+
const onCreateNew = vi.fn();
|
|
703
|
+
const onValueChange = vi.fn();
|
|
704
|
+
render(
|
|
705
|
+
<Combobox
|
|
706
|
+
options={people}
|
|
707
|
+
allowCreate
|
|
708
|
+
multiple
|
|
709
|
+
onCreateNew={onCreateNew}
|
|
710
|
+
onValueChange={onValueChange}
|
|
711
|
+
/>,
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const input = screen.getByRole('combobox');
|
|
715
|
+
await userEvent.type(input, 'alice johnson');
|
|
716
|
+
await userEvent.keyboard('{Enter}');
|
|
717
|
+
|
|
718
|
+
expect(onCreateNew).not.toHaveBeenCalled();
|
|
719
|
+
expect(onValueChange).toHaveBeenCalledWith(['alice']);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('allowCreate does not show a create row when the query only differs by surrounding spaces', async () => {
|
|
723
|
+
const onCreateNew = vi.fn();
|
|
724
|
+
render(<Combobox options={people} allowCreate onCreateNew={onCreateNew} />);
|
|
725
|
+
|
|
726
|
+
const input = screen.getByRole('combobox');
|
|
727
|
+
await userEvent.type(input, ' Alice Johnson ');
|
|
728
|
+
|
|
729
|
+
expect(screen.queryByText(/Create/)).not.toBeInTheDocument();
|
|
730
|
+
await userEvent.keyboard('{Enter}');
|
|
731
|
+
|
|
732
|
+
expect(onCreateNew).not.toHaveBeenCalled();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('allowCreate auto-highlights create row so Enter creates immediately', async () => {
|
|
736
|
+
const onCreateNew = vi.fn((name: string) => ({
|
|
737
|
+
value: name.toLowerCase(),
|
|
738
|
+
label: name,
|
|
739
|
+
}));
|
|
740
|
+
const onValueChange = vi.fn();
|
|
741
|
+
render(
|
|
742
|
+
<Combobox
|
|
743
|
+
options={people}
|
|
744
|
+
allowCreate
|
|
745
|
+
onCreateNew={onCreateNew}
|
|
746
|
+
onValueChange={onValueChange}
|
|
747
|
+
/>,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const input = screen.getByRole('combobox');
|
|
751
|
+
await userEvent.type(input, 'Zed');
|
|
752
|
+
|
|
753
|
+
const createOption = screen.getByRole('option', { name: /Create/i });
|
|
754
|
+
expect(createOption).toHaveClass('ds-combobox__option--highlighted');
|
|
755
|
+
|
|
756
|
+
await userEvent.keyboard('{Enter}');
|
|
757
|
+
expect(onCreateNew).toHaveBeenCalledWith('Zed');
|
|
758
|
+
expect(onValueChange).toHaveBeenCalledWith(['zed']);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test('clear-all button removes all selections', async () => {
|
|
762
|
+
const onValueChange = vi.fn();
|
|
763
|
+
render(
|
|
764
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} showClearAll onValueChange={onValueChange} />,
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const clearAll = screen.getByText('Clear all');
|
|
768
|
+
await userEvent.click(clearAll);
|
|
769
|
+
|
|
770
|
+
expect(onValueChange).toHaveBeenCalledWith([]);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('clear-all is hidden when nothing is selected', () => {
|
|
774
|
+
render(<Combobox options={people} multiple showClearAll />);
|
|
775
|
+
expect(screen.queryByText('Clear all')).not.toBeInTheDocument();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('disabled combobox cannot be interacted with', async () => {
|
|
779
|
+
render(<Combobox options={people} disabled placeholder="Disabled" />);
|
|
780
|
+
const input = screen.getByRole('combobox');
|
|
781
|
+
expect(input).toBeDisabled();
|
|
782
|
+
|
|
783
|
+
await userEvent.click(input);
|
|
784
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('disabled options cannot be selected via click', async () => {
|
|
788
|
+
const onValueChange = vi.fn();
|
|
789
|
+
const opts: ComboboxOption[] = [
|
|
790
|
+
{ value: 'a', label: 'Enabled' },
|
|
791
|
+
{ value: 'b', label: 'Disabled', disabled: true },
|
|
792
|
+
];
|
|
793
|
+
render(<Combobox options={opts} onValueChange={onValueChange} />);
|
|
794
|
+
|
|
795
|
+
await openWithChevron();
|
|
796
|
+
await userEvent.click(screen.getByText('Disabled'));
|
|
797
|
+
|
|
798
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('renders option groups with headers', async () => {
|
|
802
|
+
const grouped: ComboboxOption[] = [
|
|
803
|
+
{ value: 'a', label: 'Alice', group: 'Team A' },
|
|
804
|
+
{ value: 'b', label: 'Bob', group: 'Team B' },
|
|
805
|
+
];
|
|
806
|
+
render(<Combobox options={grouped} />);
|
|
807
|
+
|
|
808
|
+
await openWithChevron();
|
|
809
|
+
|
|
810
|
+
expect(screen.getByText('Team A')).toBeInTheDocument();
|
|
811
|
+
expect(screen.getByText('Team B')).toBeInTheDocument();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('multi-select shows aria-selected on selected options', async () => {
|
|
815
|
+
render(<Combobox options={people} multiple defaultValue={['alice']} />);
|
|
816
|
+
await openWithChevron();
|
|
817
|
+
|
|
818
|
+
const options = screen.getAllByRole('option');
|
|
819
|
+
const aliceOption = options.find(o => o.textContent?.includes('Alice'));
|
|
820
|
+
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test('listbox has aria-multiselectable in multi mode', async () => {
|
|
824
|
+
render(<Combobox options={people} multiple />);
|
|
825
|
+
await openWithChevron();
|
|
826
|
+
|
|
827
|
+
const listbox = screen.getByRole('listbox');
|
|
828
|
+
expect(listbox).toHaveAttribute('aria-multiselectable', 'true');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test('loading state sets aria-busy, role=status (spinner only, accessible name), and hides options', async () => {
|
|
832
|
+
render(
|
|
833
|
+
<Combobox options={people} loading onSearch={() => {}} aria-label="People" />,
|
|
834
|
+
);
|
|
835
|
+
await openWithChevron();
|
|
836
|
+
|
|
837
|
+
const input = screen.getByRole('combobox');
|
|
838
|
+
const listbox = screen.getByRole('listbox');
|
|
839
|
+
expect(listbox).toHaveAttribute('aria-busy', 'true');
|
|
840
|
+
expect(input).toHaveAttribute('aria-busy', 'true');
|
|
841
|
+
|
|
842
|
+
const status = screen.getByRole('status', { name: 'Loading suggestions' });
|
|
843
|
+
expect(status.querySelector('.ds-combobox__loading-spinner')).toBeInTheDocument();
|
|
844
|
+
expect(within(listbox).queryAllByRole('option')).toHaveLength(0);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test('loading is false does not set aria-busy on listbox', async () => {
|
|
848
|
+
render(<Combobox options={people} onSearch={() => {}} />);
|
|
849
|
+
await openWithChevron();
|
|
850
|
+
|
|
851
|
+
expect(screen.getByRole('listbox')).not.toHaveAttribute('aria-busy');
|
|
852
|
+
expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-busy');
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
describe('delete created items', () => {
|
|
856
|
+
const setupCreate = () => {
|
|
857
|
+
const onCreateNew = vi.fn((name: string) => ({
|
|
858
|
+
value: name.toLowerCase().replace(/\s/g, '-'),
|
|
859
|
+
label: name,
|
|
860
|
+
}));
|
|
861
|
+
const onDeleteCreated = vi.fn();
|
|
862
|
+
const onValueChange = vi.fn();
|
|
863
|
+
|
|
864
|
+
render(
|
|
865
|
+
<Combobox
|
|
866
|
+
options={people}
|
|
867
|
+
multiple
|
|
868
|
+
allowCreate
|
|
869
|
+
onCreateNew={onCreateNew}
|
|
870
|
+
onDeleteCreated={onDeleteCreated}
|
|
871
|
+
onValueChange={onValueChange}
|
|
872
|
+
/>,
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
return { onCreateNew, onDeleteCreated, onValueChange };
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const highlightCreatedOption = async () => {
|
|
879
|
+
const input = screen.getByRole('combobox');
|
|
880
|
+
const listbox = screen.getByRole('listbox');
|
|
881
|
+
const createdRow = within(listbox).getByText('NewPerson').closest('.ds-combobox__option-row');
|
|
882
|
+
expect(createdRow).toBeInTheDocument();
|
|
883
|
+
fireEvent.mouseEnter(createdRow!);
|
|
884
|
+
input.focus();
|
|
885
|
+
return input;
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
test('created items show a trash button with title `Delete ${option.label}`', async () => {
|
|
889
|
+
setupCreate();
|
|
890
|
+
const input = screen.getByRole('combobox');
|
|
891
|
+
|
|
892
|
+
await userEvent.type(input, 'NewPerson');
|
|
893
|
+
await userEvent.keyboard('{Enter}');
|
|
894
|
+
|
|
895
|
+
await openWithChevron();
|
|
896
|
+
|
|
897
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
898
|
+
expect(deleteBtn).toBeInTheDocument();
|
|
899
|
+
expect(deleteBtn).toHaveAttribute('title', 'Delete NewPerson');
|
|
900
|
+
expect(deleteBtn).toHaveAttribute('tabindex', '-1');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test('original options do not show trash button', async () => {
|
|
904
|
+
setupCreate();
|
|
905
|
+
await openWithChevron();
|
|
906
|
+
|
|
907
|
+
expect(screen.queryByTitle('Delete NewPerson')).not.toBeInTheDocument();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test('ArrowRight from a highlighted created item focuses its delete button', async () => {
|
|
911
|
+
setupCreate();
|
|
912
|
+
const input = screen.getByRole('combobox');
|
|
913
|
+
|
|
914
|
+
await userEvent.type(input, 'NewPerson');
|
|
915
|
+
await userEvent.keyboard('{Enter}');
|
|
916
|
+
await openWithChevron();
|
|
917
|
+
await highlightCreatedOption();
|
|
918
|
+
|
|
919
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
920
|
+
expect(deleteBtn).toHaveAttribute('tabindex', '0');
|
|
921
|
+
|
|
922
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
923
|
+
expect(deleteBtn).toHaveFocus();
|
|
924
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test('ArrowLeft from a created delete button returns focus to the input', async () => {
|
|
928
|
+
setupCreate();
|
|
929
|
+
const input = screen.getByRole('combobox');
|
|
930
|
+
|
|
931
|
+
await userEvent.type(input, 'NewPerson');
|
|
932
|
+
await userEvent.keyboard('{Enter}');
|
|
933
|
+
await openWithChevron();
|
|
934
|
+
await highlightCreatedOption();
|
|
935
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
936
|
+
|
|
937
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
938
|
+
expect(deleteBtn).toHaveFocus();
|
|
939
|
+
|
|
940
|
+
await userEvent.keyboard('{ArrowLeft}');
|
|
941
|
+
expect(input).toHaveFocus();
|
|
942
|
+
expect(deleteBtn).toHaveAttribute('tabindex', '0');
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('Tab and Shift+Tab move between the input and the highlighted created delete button', async () => {
|
|
946
|
+
setupCreate();
|
|
947
|
+
const input = screen.getByRole('combobox');
|
|
948
|
+
|
|
949
|
+
await userEvent.type(input, 'NewPerson');
|
|
950
|
+
await userEvent.keyboard('{Enter}');
|
|
951
|
+
await openWithChevron();
|
|
952
|
+
await highlightCreatedOption();
|
|
953
|
+
|
|
954
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
955
|
+
await userEvent.tab();
|
|
956
|
+
expect(deleteBtn).toHaveFocus();
|
|
957
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
|
958
|
+
|
|
959
|
+
await userEvent.tab({ shift: true });
|
|
960
|
+
expect(input).toHaveFocus();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test('clicking trash removes the option from the list and fires onDeleteCreated', async () => {
|
|
964
|
+
const { onDeleteCreated } = setupCreate();
|
|
965
|
+
const input = screen.getByRole('combobox');
|
|
966
|
+
|
|
967
|
+
await userEvent.type(input, 'NewPerson');
|
|
968
|
+
await userEvent.keyboard('{Enter}');
|
|
969
|
+
|
|
970
|
+
await openWithChevron();
|
|
971
|
+
|
|
972
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
973
|
+
await userEvent.click(deleteBtn);
|
|
974
|
+
|
|
975
|
+
expect(onDeleteCreated).toHaveBeenCalledWith('newperson');
|
|
976
|
+
|
|
977
|
+
const listbox = screen.queryByRole('listbox');
|
|
978
|
+
if (listbox) {
|
|
979
|
+
const remainingOptions = within(listbox).getAllByRole('option');
|
|
980
|
+
const labels = remainingOptions.map(o => o.textContent);
|
|
981
|
+
expect(labels.some(l => l?.includes('NewPerson'))).toBe(false);
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test('deleting a selected item also removes the chip', async () => {
|
|
986
|
+
const { onValueChange } = setupCreate();
|
|
987
|
+
const input = screen.getByRole('combobox');
|
|
988
|
+
|
|
989
|
+
await userEvent.type(input, 'NewPerson');
|
|
990
|
+
await userEvent.keyboard('{Enter}');
|
|
991
|
+
|
|
992
|
+
await openWithChevron();
|
|
993
|
+
|
|
994
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
995
|
+
await userEvent.click(deleteBtn);
|
|
996
|
+
|
|
997
|
+
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('trash click does not select/deselect the option (stopPropagation)', async () => {
|
|
1001
|
+
const { onValueChange } = setupCreate();
|
|
1002
|
+
const input = screen.getByRole('combobox');
|
|
1003
|
+
|
|
1004
|
+
await userEvent.type(input, 'NewPerson');
|
|
1005
|
+
await userEvent.keyboard('{Enter}');
|
|
1006
|
+
|
|
1007
|
+
onValueChange.mockClear();
|
|
1008
|
+
|
|
1009
|
+
await openWithChevron();
|
|
1010
|
+
const deleteBtn = screen.getByRole('button', { name: 'Delete NewPerson' });
|
|
1011
|
+
await userEvent.click(deleteBtn);
|
|
1012
|
+
|
|
1013
|
+
const calls = onValueChange.mock.calls;
|
|
1014
|
+
expect(calls.length).toBe(1);
|
|
1015
|
+
expect(calls[0]![0]).toEqual([]);
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
describe('chip keyboard navigation', () => {
|
|
1020
|
+
test('ArrowLeft at caret 0 focuses the last chip', () => {
|
|
1021
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1022
|
+
const input = screen.getByRole('combobox');
|
|
1023
|
+
input.focus();
|
|
1024
|
+
|
|
1025
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1026
|
+
|
|
1027
|
+
const tags = document.querySelectorAll('.ds-tag');
|
|
1028
|
+
expect(tags[1]).toHaveClass('ds-tag--selected');
|
|
1029
|
+
expect(tags[0]).not.toHaveClass('ds-tag--selected');
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('ArrowLeft navigates through chips from right to left', () => {
|
|
1033
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob', 'charlie']} />);
|
|
1034
|
+
const input = screen.getByRole('combobox');
|
|
1035
|
+
input.focus();
|
|
1036
|
+
|
|
1037
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1038
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1039
|
+
|
|
1040
|
+
const tags = document.querySelectorAll('.ds-tag');
|
|
1041
|
+
expect(tags[1]).toHaveClass('ds-tag--selected');
|
|
1042
|
+
expect(tags[2]).not.toHaveClass('ds-tag--selected');
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test('ArrowLeft does not go past the first chip', () => {
|
|
1046
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1047
|
+
const input = screen.getByRole('combobox');
|
|
1048
|
+
input.focus();
|
|
1049
|
+
|
|
1050
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1051
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1052
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1053
|
+
|
|
1054
|
+
const tags = document.querySelectorAll('.ds-tag');
|
|
1055
|
+
expect(tags[0]).toHaveClass('ds-tag--selected');
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test('ArrowRight past the last chip returns focus to input (exits chip nav)', () => {
|
|
1059
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1060
|
+
const input = screen.getByRole('combobox');
|
|
1061
|
+
input.focus();
|
|
1062
|
+
|
|
1063
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1064
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
|
|
1065
|
+
|
|
1066
|
+
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
|
1067
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('Backspace on focused chip removes it', () => {
|
|
1071
|
+
const onValueChange = vi.fn();
|
|
1072
|
+
render(
|
|
1073
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob', 'charlie']} onValueChange={onValueChange} />,
|
|
1074
|
+
);
|
|
1075
|
+
const input = screen.getByRole('combobox');
|
|
1076
|
+
input.focus();
|
|
1077
|
+
|
|
1078
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1079
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1080
|
+
fireEvent.keyDown(input, { key: 'Backspace' });
|
|
1081
|
+
|
|
1082
|
+
expect(onValueChange).toHaveBeenCalledWith(['alice', 'charlie']);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test('Delete on focused chip removes it', () => {
|
|
1086
|
+
const onValueChange = vi.fn();
|
|
1087
|
+
render(
|
|
1088
|
+
<Combobox options={people} multiple defaultValue={['alice', 'bob']} onValueChange={onValueChange} />,
|
|
1089
|
+
);
|
|
1090
|
+
const input = screen.getByRole('combobox');
|
|
1091
|
+
input.focus();
|
|
1092
|
+
|
|
1093
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1094
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1095
|
+
fireEvent.keyDown(input, { key: 'Delete' });
|
|
1096
|
+
|
|
1097
|
+
expect(onValueChange).toHaveBeenCalledWith(['bob']);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
test('Backspace on first chip focuses next or exits', () => {
|
|
1101
|
+
const onValueChange = vi.fn();
|
|
1102
|
+
render(
|
|
1103
|
+
<Combobox options={people} multiple defaultValue={['alice']} onValueChange={onValueChange} />,
|
|
1104
|
+
);
|
|
1105
|
+
const input = screen.getByRole('combobox');
|
|
1106
|
+
input.focus();
|
|
1107
|
+
|
|
1108
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1109
|
+
fireEvent.keyDown(input, { key: 'Backspace' });
|
|
1110
|
+
|
|
1111
|
+
expect(onValueChange).toHaveBeenCalledWith([]);
|
|
1112
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('typing exits chip nav mode', async () => {
|
|
1116
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1117
|
+
const input = screen.getByRole('combobox');
|
|
1118
|
+
input.focus();
|
|
1119
|
+
|
|
1120
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1121
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
|
|
1122
|
+
|
|
1123
|
+
await userEvent.type(input, 'c');
|
|
1124
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
test('Escape exits chip nav mode', () => {
|
|
1128
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1129
|
+
const input = screen.getByRole('combobox');
|
|
1130
|
+
input.focus();
|
|
1131
|
+
|
|
1132
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1133
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
|
|
1134
|
+
|
|
1135
|
+
fireEvent.keyDown(input, { key: 'Escape' });
|
|
1136
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test('Cmd+A during chip nav enters bulk-select-all mode', () => {
|
|
1140
|
+
render(<Combobox options={people} multiple defaultValue={['alice', 'bob']} />);
|
|
1141
|
+
const input = screen.getByRole('combobox');
|
|
1142
|
+
input.focus();
|
|
1143
|
+
|
|
1144
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1145
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(1);
|
|
1146
|
+
|
|
1147
|
+
fireEvent.keyDown(input, { key: 'a', metaKey: true });
|
|
1148
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(2);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test('ArrowLeft does nothing when input has text and caret is not at 0', async () => {
|
|
1152
|
+
render(<Combobox options={people} multiple defaultValue={['alice']} />);
|
|
1153
|
+
const input = screen.getByRole('combobox');
|
|
1154
|
+
await userEvent.type(input, 'test');
|
|
1155
|
+
|
|
1156
|
+
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
|
1157
|
+
expect(document.querySelectorAll('.ds-tag--selected')).toHaveLength(0);
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
});
|