@spectral_labs/ui 1.6.1 → 1.8.0-next.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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/THIRD_PARTY_NOTICES.md +201 -0
  3. package/dist/components/command/command.svelte.d.ts +30 -0
  4. package/dist/components/command/command.svelte.d.ts.map +1 -1
  5. package/dist/components/command/command.svelte.js +85 -4
  6. package/dist/components/command/components/command-group.svelte +25 -15
  7. package/dist/components/command/components/command-group.svelte.d.ts.map +1 -1
  8. package/dist/components/command/components/command-input.svelte +54 -5
  9. package/dist/components/command/components/command-input.svelte.d.ts +1 -1
  10. package/dist/components/command/components/command-input.svelte.d.ts.map +1 -1
  11. package/dist/components/command/components/command-list.svelte +22 -3
  12. package/dist/components/command/components/command-list.svelte.d.ts.map +1 -1
  13. package/dist/components/command/components/command-root.svelte +43 -4
  14. package/dist/components/command/components/command-root.svelte.d.ts.map +1 -1
  15. package/dist/components/command/index.d.ts +1 -0
  16. package/dist/components/command/index.d.ts.map +1 -1
  17. package/dist/components/command/types.d.ts +59 -4
  18. package/dist/components/command/types.d.ts.map +1 -1
  19. package/package.json +8 -2
  20. package/dist/components/__tests__/overlay-scrim-source.test.js +0 -134
  21. package/dist/components/accordion/__tests__/AccordionTest.svelte +0 -31
  22. package/dist/components/accordion/__tests__/accordion.test.svelte.js +0 -139
  23. package/dist/components/alert-dialog/__tests__/AlertDialogTest.svelte +0 -28
  24. package/dist/components/alert-dialog/__tests__/alert-dialog.a11y.test.js +0 -42
  25. package/dist/components/alert-dialog/__tests__/alert-dialog.integration.test.svelte.js +0 -51
  26. package/dist/components/alert-dialog/__tests__/alert-dialog.test.svelte.js +0 -113
  27. package/dist/components/app-bar/__tests__/app-bar.test.svelte.js +0 -30
  28. package/dist/components/aspect-ratio/__tests__/aspect-ratio.test.svelte.js +0 -22
  29. package/dist/components/avatar/__tests__/avatar.test.svelte.js +0 -26
  30. package/dist/components/badge/__tests__/badge.test.svelte.js +0 -30
  31. package/dist/components/bottom-sheet/__tests__/BottomSheetTest.svelte +0 -22
  32. package/dist/components/bottom-sheet/__tests__/bottom-sheet.test.svelte.js +0 -83
  33. package/dist/components/button/__tests__/button.test.svelte.js +0 -63
  34. package/dist/components/calendar/__tests__/CalendarTest.svelte +0 -56
  35. package/dist/components/calendar/__tests__/calendar.test.svelte.js +0 -190
  36. package/dist/components/card/__tests__/card.test.svelte.js +0 -30
  37. package/dist/components/checkbox/__tests__/checkbox.test.svelte.js +0 -44
  38. package/dist/components/chips/__tests__/chips.test.svelte.js +0 -26
  39. package/dist/components/collapsible/__tests__/CollapsibleTest.svelte +0 -18
  40. package/dist/components/collapsible/__tests__/collapsible.test.svelte.js +0 -125
  41. package/dist/components/combobox/__tests__/ComboboxTest.svelte +0 -40
  42. package/dist/components/combobox/__tests__/combobox.test.svelte.js +0 -255
  43. package/dist/components/command/__tests__/CommandTest.svelte +0 -77
  44. package/dist/components/command/__tests__/command-item-passthrough.test.svelte.js +0 -284
  45. package/dist/components/command/__tests__/command.a11y.test.js +0 -30
  46. package/dist/components/command/__tests__/command.integration.test.svelte.js +0 -68
  47. package/dist/components/command/__tests__/command.test.svelte.js +0 -230
  48. package/dist/components/context-menu/__tests__/ContextMenuNonInteractiveTest.svelte +0 -32
  49. package/dist/components/context-menu/__tests__/ContextMenuTest.svelte +0 -25
  50. package/dist/components/context-menu/__tests__/context-menu.test.svelte.js +0 -141
  51. package/dist/components/date-field/__tests__/DateFieldTest.svelte +0 -20
  52. package/dist/components/date-field/__tests__/date-field.test.svelte.js +0 -139
  53. package/dist/components/date-picker/__tests__/DatePickerTest.svelte +0 -20
  54. package/dist/components/date-picker/__tests__/date-picker.test.svelte.js +0 -190
  55. package/dist/components/date-range-field/__tests__/DateRangeFieldTest.svelte +0 -20
  56. package/dist/components/date-range-field/__tests__/date-range-field.test.svelte.js +0 -130
  57. package/dist/components/date-range-picker/__tests__/DateRangePickerTest.svelte +0 -18
  58. package/dist/components/date-range-picker/__tests__/date-range-picker.test.svelte.js +0 -156
  59. package/dist/components/dialog/__tests__/DialogTest.svelte +0 -34
  60. package/dist/components/dialog/__tests__/dialog.test.svelte.js +0 -184
  61. package/dist/components/drop-zone/__tests__/drop-zone.test.svelte.js +0 -26
  62. package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +0 -90
  63. package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +0 -71
  64. package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +0 -306
  65. package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +0 -567
  66. package/dist/components/empty-state/__tests__/empty-state.test.svelte.js +0 -26
  67. package/dist/components/fab/__tests__/fab.test.svelte.js +0 -30
  68. package/dist/components/field/__tests__/FieldTest.svelte +0 -26
  69. package/dist/components/field/__tests__/field.test.svelte.js +0 -74
  70. package/dist/components/grid-list/__tests__/GridListTest.svelte +0 -46
  71. package/dist/components/grid-list/__tests__/grid-list.test.svelte.js +0 -231
  72. package/dist/components/hover-card/__tests__/HoverCardTest.svelte +0 -30
  73. package/dist/components/hover-card/__tests__/hover-card.test.svelte.js +0 -236
  74. package/dist/components/indicator/__tests__/indicator.test.svelte.js +0 -30
  75. package/dist/components/input/__tests__/input.test.svelte.js +0 -67
  76. package/dist/components/label/__tests__/label.test.svelte.js +0 -22
  77. package/dist/components/language-select/__tests__/language-select.test.svelte.js +0 -199
  78. package/dist/components/language-select/__tests__/languages.test.js +0 -87
  79. package/dist/components/link-preview/__tests__/LinkPreviewTest.svelte +0 -37
  80. package/dist/components/link-preview/__tests__/link-preview.a11y.test.js +0 -28
  81. package/dist/components/link-preview/__tests__/link-preview.integration.test.svelte.js +0 -54
  82. package/dist/components/link-preview/__tests__/link-preview.test.svelte.js +0 -215
  83. package/dist/components/list/__tests__/list.test.svelte.js +0 -22
  84. package/dist/components/menubar/__tests__/MenubarTest.svelte +0 -52
  85. package/dist/components/menubar/__tests__/menubar.a11y.test.js +0 -28
  86. package/dist/components/menubar/__tests__/menubar.integration.test.svelte.js +0 -57
  87. package/dist/components/menubar/__tests__/menubar.test.svelte.js +0 -328
  88. package/dist/components/meter/__tests__/meter.test.svelte.js +0 -26
  89. package/dist/components/nav-bar/__tests__/nav-bar.test.svelte.js +0 -23
  90. package/dist/components/nav-drawer/__tests__/nav-drawer.test.svelte.js +0 -28
  91. package/dist/components/nav-rail/__tests__/nav-rail.test.svelte.js +0 -28
  92. package/dist/components/pagination/__tests__/pagination.test.svelte.js +0 -26
  93. package/dist/components/pin-input/__tests__/pin-input.test.svelte.js +0 -71
  94. package/dist/components/popover/__tests__/PopoverTest.svelte +0 -27
  95. package/dist/components/popover/__tests__/popover.test.svelte.js +0 -196
  96. package/dist/components/progress/__tests__/progress.test.svelte.js +0 -32
  97. package/dist/components/radio-group/__tests__/radio-group.test.svelte.js +0 -22
  98. package/dist/components/range-calendar/__tests__/RangeCalendarTest.svelte +0 -23
  99. package/dist/components/range-calendar/__tests__/range-calendar.test.svelte.js +0 -105
  100. package/dist/components/rating-group/__tests__/rating-group.test.svelte.js +0 -26
  101. package/dist/components/scroll-area/__tests__/scroll-area.test.svelte.js +0 -34
  102. package/dist/components/search-field/__tests__/SearchFieldTest.svelte +0 -34
  103. package/dist/components/search-field/__tests__/search-field.test.svelte.js +0 -118
  104. package/dist/components/select/__tests__/SelectLabelPersistenceTest.svelte +0 -31
  105. package/dist/components/select/__tests__/SelectTest.svelte +0 -33
  106. package/dist/components/select/__tests__/select.test.svelte.js +0 -221
  107. package/dist/components/separator/__tests__/separator.test.svelte.js +0 -39
  108. package/dist/components/sheet/__tests__/SheetTest.svelte +0 -34
  109. package/dist/components/sheet/__tests__/sheet.test.svelte.js +0 -193
  110. package/dist/components/side-sheet/__tests__/side-sheet.test.svelte.js +0 -138
  111. package/dist/components/sidebar/__tests__/SidebarFullTest.svelte +0 -45
  112. package/dist/components/sidebar/__tests__/SidebarTest.svelte +0 -9
  113. package/dist/components/sidebar/__tests__/sidebar.test.svelte.js +0 -186
  114. package/dist/components/simple-dialog/__tests__/SimpleDialogTest.svelte +0 -40
  115. package/dist/components/simple-dialog/__tests__/simple-dialog.test.svelte.js +0 -50
  116. package/dist/components/simple-tooltip/__tests__/SimpleTooltipTest.svelte +0 -19
  117. package/dist/components/simple-tooltip/__tests__/simple-tooltip.test.svelte.js +0 -61
  118. package/dist/components/slider/__tests__/slider.test.svelte.js +0 -42
  119. package/dist/components/sortable-list/__tests__/NestedSortableListTest.svelte +0 -49
  120. package/dist/components/sortable-list/__tests__/SortableListTest.svelte +0 -42
  121. package/dist/components/sortable-list/__tests__/sortable-list.nested.test.svelte.js +0 -112
  122. package/dist/components/sortable-list/__tests__/sortable-list.test.svelte.js +0 -175
  123. package/dist/components/splitter/__tests__/splitter.test.svelte.js +0 -26
  124. package/dist/components/switch/__tests__/switch.test.svelte.js +0 -44
  125. package/dist/components/table/__tests__/table.test.svelte.js +0 -22
  126. package/dist/components/tabs/__tests__/TabsTest.svelte +0 -32
  127. package/dist/components/tabs/__tests__/tabs.test.svelte.js +0 -227
  128. package/dist/components/textarea/__tests__/textarea.test.svelte.js +0 -55
  129. package/dist/components/time-field/__tests__/TimeFieldTest.svelte +0 -20
  130. package/dist/components/time-field/__tests__/time-field.test.svelte.js +0 -100
  131. package/dist/components/time-range-field/__tests__/TimeRangeFieldTest.svelte +0 -20
  132. package/dist/components/time-range-field/__tests__/time-range-field.test.svelte.js +0 -124
  133. package/dist/components/toast/__tests__/toast.test.svelte.js +0 -46
  134. package/dist/components/toggle/__tests__/toggle.test.svelte.js +0 -38
  135. package/dist/components/toggle-group/__tests__/toggle-group.test.svelte.js +0 -22
  136. package/dist/components/toolbar/__tests__/ToolbarCompositionTest.svelte +0 -18
  137. package/dist/components/toolbar/__tests__/ToolbarTabstopTest.svelte +0 -10
  138. package/dist/components/toolbar/__tests__/toolbar.test.svelte.js +0 -63
  139. package/dist/components/tooltip/__tests__/TooltipTest.svelte +0 -22
  140. package/dist/components/tooltip/__tests__/tooltip.test.svelte.js +0 -225
  141. package/dist/components/tree/__tests__/TreeLabelContentTest.svelte +0 -21
  142. package/dist/components/tree/__tests__/TreeTest.svelte +0 -24
  143. package/dist/components/tree/__tests__/tree.test.svelte.js +0 -229
  144. package/dist/components/virtualizer/__tests__/waterfall-layout.test.js +0 -82
  145. package/dist/internal/announcer/__tests__/announcer.test.js +0 -42
  146. package/dist/internal/dismissible-layer/__tests__/dismissible-layer.test.js +0 -196
  147. package/dist/internal/drag-drop/__tests__/auto-scroll.test.js +0 -104
  148. package/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +0 -803
  149. package/dist/internal/drag-drop/__tests__/drag-source.test.svelte.js +0 -130
  150. package/dist/internal/drag-drop/__tests__/drop-delegate.test.js +0 -223
  151. package/dist/internal/drag-drop/__tests__/types.test.js +0 -20
  152. package/dist/internal/escape-layer/__tests__/escape-layer.test.js +0 -113
  153. package/dist/internal/focus-scope/__tests__/focus-scope.test.js +0 -225
  154. package/dist/internal/grace-area/__tests__/grace-area.test.js +0 -67
  155. package/dist/internal/helpers/__tests__/helpers.test.js +0 -123
  156. package/dist/internal/portal/__tests__/PortalTest.svelte +0 -21
  157. package/dist/internal/portal/__tests__/portal.test.js +0 -29
  158. package/dist/internal/portal/__tests__/portal.test.svelte.js +0 -57
  159. package/dist/internal/scroll-lock/__tests__/scroll-lock.test.js +0 -160
  160. package/dist/internal/typeahead/__tests__/typeahead.test.js +0 -61
  161. package/src/themes/__tests__/carbon-dark-contrast.test.ts +0 -170
  162. package/src/themes/__tests__/generated-css-drift.test.ts +0 -53
  163. package/src/themes/__tests__/scrim-opaque.test.ts +0 -52
@@ -1,255 +0,0 @@
1
- import { flushSync, mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import ComboboxRoot from '../components/combobox-root.svelte';
4
- import ComboboxTest from './ComboboxTest.svelte';
5
- describe('combobox', () => {
6
- let target;
7
- let instance;
8
- function setup(props = {}) {
9
- target = document.createElement('div');
10
- document.body.appendChild(target);
11
- instance = mount(ComboboxTest, { target, props: { portalTo: target, ...props } });
12
- flushSync();
13
- return target;
14
- }
15
- function setupRoot(props = {}) {
16
- target = document.createElement('div');
17
- document.body.appendChild(target);
18
- instance = mount(ComboboxRoot, { target, props });
19
- return target;
20
- }
21
- afterEach(() => {
22
- if (instance)
23
- unmount(instance);
24
- target?.remove();
25
- });
26
- // --- Existing tests ---
27
- it('renders closed by default', () => {
28
- setupRoot();
29
- const root = target.querySelector('.sp-combobox-root');
30
- expect(root?.getAttribute('data-state')).toBe('closed');
31
- });
32
- it('renders hidden input when name is provided', () => {
33
- setupRoot({ name: 'fruit', value: 'Apple' });
34
- const input = target.querySelector('input[type="hidden"][name="fruit"]');
35
- expect(input).toBeTruthy();
36
- expect(input?.value).toBe('Apple');
37
- });
38
- // --- Input ARIA ---
39
- it('input has role="combobox"', () => {
40
- setup();
41
- const input = target.querySelector('[role="combobox"]');
42
- expect(input).toBeTruthy();
43
- });
44
- it('input has aria-expanded="false" when closed', () => {
45
- setup();
46
- const input = target.querySelector('[role="combobox"]');
47
- expect(input?.getAttribute('aria-expanded')).toBe('false');
48
- });
49
- it('input has aria-autocomplete="list"', () => {
50
- setup();
51
- const input = target.querySelector('[role="combobox"]');
52
- expect(input?.getAttribute('aria-autocomplete')).toBe('list');
53
- });
54
- it('input has autocomplete="off"', () => {
55
- setup();
56
- const input = target.querySelector('[role="combobox"]');
57
- expect(input?.autocomplete).toBe('off');
58
- });
59
- // --- Open on focus ---
60
- it('opens on input focus', () => {
61
- setup();
62
- const input = target.querySelector('[role="combobox"]');
63
- input.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
64
- flushSync();
65
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('open');
66
- });
67
- // --- Keyboard navigation ---
68
- it('arrowDown opens listbox and navigates to next item', () => {
69
- setup();
70
- const input = target.querySelector('[role="combobox"]');
71
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
72
- flushSync();
73
- // Should be open
74
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('open');
75
- });
76
- it('escape closes the listbox', () => {
77
- setup();
78
- const input = target.querySelector('[role="combobox"]');
79
- // Open first
80
- input.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
81
- flushSync();
82
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('open');
83
- // Escape
84
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
85
- flushSync();
86
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('closed');
87
- });
88
- // --- IME composition guard ---
89
- it('ignores keydown during IME composition', () => {
90
- setup();
91
- const input = target.querySelector('[role="combobox"]');
92
- // Simulate IME composition event (isComposing: true)
93
- const event = new KeyboardEvent('keydown', {
94
- key: 'ArrowDown',
95
- bubbles: true,
96
- composed: true,
97
- });
98
- // Override isComposing since KeyboardEvent constructor doesn't support it directly
99
- Object.defineProperty(event, 'isComposing', { value: true });
100
- input.dispatchEvent(event);
101
- flushSync();
102
- // Should NOT have opened because isComposing was true
103
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('closed');
104
- });
105
- it('calls onOpenChange with new value on each open transition', () => {
106
- const calls = [];
107
- const localTarget = document.createElement('div');
108
- document.body.appendChild(localTarget);
109
- const localInstance = mount(ComboboxTest, {
110
- target: localTarget,
111
- props: {
112
- open: false,
113
- portalTo: localTarget,
114
- onOpenChange: (open) => calls.push(open),
115
- },
116
- });
117
- flushSync();
118
- try {
119
- const input = localTarget.querySelector('.sp-combobox-input');
120
- input.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
121
- flushSync();
122
- expect(calls).toEqual([true]);
123
- input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
124
- flushSync();
125
- expect(calls).toEqual([true, false]);
126
- }
127
- finally {
128
- unmount(localInstance);
129
- localTarget.remove();
130
- }
131
- });
132
- it('processes keydown when not composing', () => {
133
- setup();
134
- const input = target.querySelector('[role="combobox"]');
135
- const event = new KeyboardEvent('keydown', {
136
- key: 'ArrowDown',
137
- bubbles: true,
138
- });
139
- Object.defineProperty(event, 'isComposing', { value: false });
140
- input.dispatchEvent(event);
141
- flushSync();
142
- // Should have opened because isComposing was false
143
- expect(target.querySelector('.sp-combobox-root')?.getAttribute('data-state')).toBe('open');
144
- });
145
- describe('content positioning (FloatingLayerState)', () => {
146
- // Floating UI's computePosition is async; give it a microtask to run.
147
- async function openAndWaitForFloating(props = {}) {
148
- setup(props);
149
- const input = target.querySelector('[role="combobox"]');
150
- input.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
151
- flushSync();
152
- }
153
- it('applies inline position:absolute on content after opening', async () => {
154
- await openAndWaitForFloating({ disablePortal: true });
155
- const content = target.querySelector('.sp-combobox-content');
156
- await vi.waitFor(() => {
157
- expect(content.style.position).toBe('absolute');
158
- });
159
- });
160
- it('applies inline top and left written by Floating UI (not CSS 100%/0)', async () => {
161
- await openAndWaitForFloating({ disablePortal: true });
162
- const content = target.querySelector('.sp-combobox-content');
163
- await vi.waitFor(() => {
164
- // Floating UI writes pixel values — in jsdom they resolve to '0px'
165
- // since getBoundingClientRect returns zero, but the values must be
166
- // *inline styles* (not CSS rules) so the '100%' top and '0' right
167
- // that caused the bug are gone.
168
- expect(content.style.top).toMatch(/px$/);
169
- expect(content.style.left).toMatch(/px$/);
170
- });
171
- });
172
- it('accepts the side prop (default: bottom)', async () => {
173
- await openAndWaitForFloating({ disablePortal: true, side: 'top' });
174
- const content = target.querySelector('.sp-combobox-content');
175
- expect(content).toBeTruthy();
176
- });
177
- it('accepts the sideOffset prop', async () => {
178
- await openAndWaitForFloating({ disablePortal: true, sideOffset: 12 });
179
- const content = target.querySelector('.sp-combobox-content');
180
- expect(content).toBeTruthy();
181
- });
182
- it('accepts the align prop', async () => {
183
- await openAndWaitForFloating({ disablePortal: true, align: 'center' });
184
- const content = target.querySelector('.sp-combobox-content');
185
- expect(content).toBeTruthy();
186
- });
187
- it('does not stretch to full body width anymore when portaled', async () => {
188
- // Regression: the bug rendered the content with right:0 against body,
189
- // producing a full-viewport-width menu. After the fix, the content is
190
- // positioned (via FloatingLayerState) and does NOT have `right: 0`
191
- // inherited from the stylesheet.
192
- await openAndWaitForFloating({});
193
- const content = document.body.querySelector('.sp-combobox-content');
194
- await vi.waitFor(() => {
195
- const cs = window.getComputedStyle(content);
196
- // Floating UI writes `left` inline → `right` in CSS should not be 0
197
- // (the old buggy rule `right: 0` is removed). A reasonable proxy:
198
- // assert that `width` isn't pinned to viewport / body width.
199
- expect(cs.right).not.toBe('0px');
200
- });
201
- });
202
- });
203
- describe('portal opt-out (disablePortal)', () => {
204
- it('portals to document.body by default', () => {
205
- const localTarget = document.createElement('div');
206
- document.body.appendChild(localTarget);
207
- const localInstance = mount(ComboboxTest, {
208
- target: localTarget,
209
- props: { open: true },
210
- });
211
- flushSync();
212
- try {
213
- expect(localTarget.querySelector('.sp-combobox-content')).toBeNull();
214
- expect(document.body.querySelector('.sp-combobox-content')).toBeTruthy();
215
- }
216
- finally {
217
- unmount(localInstance);
218
- localTarget.remove();
219
- }
220
- });
221
- it('renders inline when disablePortal={true}', () => {
222
- const localTarget = document.createElement('div');
223
- document.body.appendChild(localTarget);
224
- const localInstance = mount(ComboboxTest, {
225
- target: localTarget,
226
- props: { open: true, disablePortal: true },
227
- });
228
- flushSync();
229
- try {
230
- expect(localTarget.querySelector('.sp-combobox-content')).toBeTruthy();
231
- }
232
- finally {
233
- unmount(localInstance);
234
- localTarget.remove();
235
- }
236
- });
237
- it('preserves consumer class on the content element when inline', () => {
238
- const localTarget = document.createElement('div');
239
- document.body.appendChild(localTarget);
240
- const localInstance = mount(ComboboxTest, {
241
- target: localTarget,
242
- props: { open: true, disablePortal: true, contentClass: 'my-consumer-class' },
243
- });
244
- flushSync();
245
- try {
246
- const el = localTarget.querySelector('.sp-combobox-content.my-consumer-class');
247
- expect(el).toBeTruthy();
248
- }
249
- finally {
250
- unmount(localInstance);
251
- localTarget.remove();
252
- }
253
- });
254
- });
255
- });
@@ -1,77 +0,0 @@
1
- <script lang="ts">
2
- import CommandEmpty from '../components/command-empty.svelte'
3
- import CommandGroup from '../components/command-group.svelte'
4
- import CommandInput from '../components/command-input.svelte'
5
- import CommandItem from '../components/command-item.svelte'
6
- import CommandList from '../components/command-list.svelte'
7
- import CommandRoot from '../components/command-root.svelte'
8
- import CommandSeparator from '../components/command-separator.svelte'
9
-
10
- let {
11
- value = $bindable(''),
12
- shouldFilter = true,
13
- open = $bindable(true),
14
- portalTo = undefined,
15
- closeOnEscape = true,
16
- closeOnOutsideClick = true,
17
- onOpenChange = undefined,
18
- onClose = undefined,
19
- itemAttrs = undefined,
20
- disabledItemAttrs = undefined,
21
- onItemSelect = undefined,
22
- onDisabledItemSelect = undefined,
23
- }: {
24
- value?: string
25
- shouldFilter?: boolean
26
- open?: boolean
27
- portalTo?: HTMLElement | string
28
- closeOnEscape?: boolean
29
- closeOnOutsideClick?: boolean
30
- onOpenChange?: (open: boolean) => void
31
- onClose?: () => void
32
- /** Arbitrary HTML attributes / native handlers spread onto the `copy` item. */
33
- itemAttrs?: Record<string, unknown>
34
- /** Same, spread onto the `settings` item (the disabled one). */
35
- disabledItemAttrs?: Record<string, unknown>
36
- /** Spectral `onSelect` of the `copy` item. */
37
- onItemSelect?: (value: string) => void
38
- /** Spectral `onSelect` of the `settings` item. */
39
- onDisabledItemSelect?: (value: string) => void
40
- } = $props()
41
- </script>
42
-
43
- <CommandRoot
44
- bind:value
45
- bind:open
46
- {shouldFilter}
47
- {portalTo}
48
- {closeOnEscape}
49
- {closeOnOutsideClick}
50
- {onOpenChange}
51
- {onClose}
52
- >
53
- <CommandInput placeholder="Type a command..." />
54
- <CommandList>
55
- <CommandEmpty>No results found.</CommandEmpty>
56
- <CommandGroup heading="Actions">
57
- <CommandItem
58
- value="copy"
59
- keywords={['duplicate']}
60
- onSelect={onItemSelect}
61
- {...itemAttrs ?? {}}>Copy</CommandItem
62
- >
63
- <CommandItem value="paste">Paste</CommandItem>
64
- <CommandItem value="cut">Cut</CommandItem>
65
- </CommandGroup>
66
- <CommandSeparator />
67
- <CommandGroup heading="Navigation">
68
- <CommandItem value="home">Home</CommandItem>
69
- <CommandItem
70
- value="settings"
71
- disabled
72
- onSelect={onDisabledItemSelect}
73
- {...disabledItemAttrs ?? {}}>Settings</CommandItem
74
- >
75
- </CommandGroup>
76
- </CommandList>
77
- </CommandRoot>
@@ -1,284 +0,0 @@
1
- import { flushSync, mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
3
- import CommandTest from './CommandTest.svelte';
4
- // jsdom does not define PointerEvent — polyfill it for tests
5
- if (typeof globalThis.PointerEvent === 'undefined') {
6
- // @ts-expect-error — minimal polyfill for testing
7
- globalThis.PointerEvent = class PointerEvent extends MouseEvent {
8
- constructor(type, init) {
9
- super(type, init);
10
- }
11
- };
12
- }
13
- /**
14
- * spectral-ui#205 — `Command.Item` forwards native HTML attributes and handlers
15
- * to its real `role="option"` node.
16
- *
17
- * Contract pinned here:
18
- * - consumer attributes (`title`, `aria-*`, `data-testid`, `style`, …) reach
19
- * the option, and a value reassigned by the parent AFTER mount is reflected;
20
- * - the option's IDENTITY stays under the component's control: `role`, `id`,
21
- * `tabindex`, `aria-selected`, `aria-disabled` and the internal `data-*`
22
- * (`data-command-item`, `data-value`, `data-selected`, `data-disabled`)
23
- * cannot be overridden — the spread comes FIRST, the identity comes after;
24
- * - native handlers COMPOSE with the built-in behaviour: the consumer's runs
25
- * first, `preventDefault()` cancels the Spectral selection, `onSelect` only
26
- * fires after a real selection;
27
- * - activation of a disabled row is inert: consumer `onclick` / `onkeydown` do
28
- * not run either, while `onpointermove` stays observable (a dimmed row can
29
- * still be hovered) without ever selecting the row.
30
- *
31
- * Tests titled `guard —` were already green on the pre-#205 tree (nothing was
32
- * forwarded, so nothing could be overridden, leaked or mis-composed); their
33
- * discriminating power comes from the mutant campaign below, each mutant
34
- * applied alone on the final tree and killed by the named test(s):
35
- * - spread placed LAST → identity guard, Enter-from-input guard
36
- * - rest not spread → forwarding, post-mount reactivity, identity
37
- * - `keywords` left in rest → no-leak guard
38
- * - consumer handler run BEFORE the disabled guard → disabled-inert guard
39
- * - onclick / onkeydown / onpointermove ignore `defaultPrevented` → cancel pins
40
- * - onkeydown not composed → onkeydown compose + cancel
41
- * - rest captured statically at init → post-mount reactivity
42
- * - `data-command-item` rendered boolean → identity
43
- * - onpointermove guarded on disabled → disabled-observable pin
44
- * - hover routed through `select()` → hover-does-not-fire-onSelect guard
45
- * - Space branch dropped → Space activation guard
46
- * - disabled keydown returns before `preventDefault()` → disabled-Space pin
47
- * - `'id'` dropped from the type's `Omit` → `src/__typecheck__` pin
48
- *
49
- * Keyboard note: Enter pressed in the INPUT activates the selected option
50
- * through a synthetic `click()` (command-input.svelte), so a consumer observes
51
- * that path through `onclick`, not `onkeydown`.
52
- */
53
- describe('command item — native attribute passthrough (#205)', () => {
54
- let target;
55
- let instance;
56
- function setup(props = {}) {
57
- target = document.createElement('div');
58
- document.body.appendChild(target);
59
- instance = mount(CommandTest, { target, props: { portalTo: target, ...props } });
60
- flushSync();
61
- flushSync(); // $effect item registration
62
- return target;
63
- }
64
- const option = (value) => target.querySelector(`[role="option"][data-value="${value}"]`);
65
- const input = () => target.querySelector('input[role="combobox"]');
66
- afterEach(() => {
67
- if (instance)
68
- unmount(instance);
69
- target?.remove();
70
- });
71
- // ── Forwarding ────────────────────────────────────────────────────────
72
- it('forwards consumer HTML attributes to the option element', () => {
73
- setup({
74
- itemAttrs: {
75
- title: 'Copy the selection',
76
- 'aria-label': 'Copy',
77
- 'aria-describedby': 'copy-hint',
78
- 'data-testid': 'opt-copy',
79
- style: 'color: red',
80
- },
81
- });
82
- const el = option('copy');
83
- expect(el.getAttribute('title')).toBe('Copy the selection');
84
- expect(el.getAttribute('aria-label')).toBe('Copy');
85
- expect(el.getAttribute('aria-describedby')).toBe('copy-hint');
86
- expect(el.getAttribute('data-testid')).toBe('opt-copy');
87
- expect(el.style.color).toBe('red');
88
- });
89
- it('reflects a consumer attribute reassigned by the parent after mount', () => {
90
- target = document.createElement('div');
91
- document.body.appendChild(target);
92
- const props = $state({
93
- portalTo: target,
94
- itemAttrs: { title: 'Before', 'aria-describedby': 'hint-before' },
95
- });
96
- instance = mount(CommandTest, { target, props });
97
- flushSync();
98
- expect(option('copy').getAttribute('title')).toBe('Before');
99
- props.itemAttrs = { title: 'After', 'aria-describedby': 'hint-after' };
100
- flushSync();
101
- expect(option('copy').getAttribute('title')).toBe('After');
102
- expect(option('copy').getAttribute('aria-describedby')).toBe('hint-after');
103
- });
104
- it('guard — keeps the DS class alongside a consumer class', () => {
105
- setup({ itemAttrs: { class: 'mine' } });
106
- const el = option('copy');
107
- expect(el.classList.contains('sp-command-item')).toBe(true);
108
- expect(el.classList.contains('mine')).toBe(true);
109
- });
110
- // ── Identity stays ours ───────────────────────────────────────────────
111
- it('keeps its identity attributes when a consumer tries to override them', () => {
112
- setup({
113
- itemAttrs: {
114
- role: 'menuitem',
115
- id: 'consumer-id',
116
- tabindex: 0,
117
- 'aria-selected': 'false',
118
- 'aria-disabled': 'true',
119
- 'data-selected': 'false',
120
- 'data-value': 'leak',
121
- 'data-disabled': '',
122
- 'data-command-item': 'nope',
123
- 'data-testid': 'still-forwarded',
124
- },
125
- });
126
- // `copy` is the first enabled item → auto-selected.
127
- const el = target.querySelector('[data-testid="still-forwarded"]');
128
- expect(el).toBeTruthy();
129
- expect(el.getAttribute('role')).toBe('option');
130
- expect(el.id).toMatch(/^sp-command-item-\d+$/);
131
- expect(el.getAttribute('tabindex')).toBe('-1');
132
- expect(el.getAttribute('aria-selected')).toBe('true');
133
- expect(el.hasAttribute('aria-disabled')).toBe(false);
134
- expect(el.getAttribute('data-selected')).toBe('true');
135
- expect(el.getAttribute('data-value')).toBe('copy');
136
- expect(el.hasAttribute('data-disabled')).toBe(false);
137
- // `data-command-item` keeps its shipped form: present, empty-valued.
138
- expect(el.hasAttribute('data-command-item')).toBe(true);
139
- expect(el.getAttribute('data-command-item')).toBe('');
140
- });
141
- it('guard — Enter in the input activates the item Spectral selected, whatever the consumer claims', () => {
142
- const selected = [];
143
- setup({
144
- itemAttrs: { 'data-selected': 'false' },
145
- onItemSelect: (v) => selected.push(v),
146
- });
147
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
148
- flushSync();
149
- expect(selected).toEqual(['copy']);
150
- });
151
- it('guard — does not leak component props to the DOM', () => {
152
- setup({ onItemSelect: () => { } });
153
- const copy = option('copy');
154
- // (`on*` keys never surface as attributes in Svelte, so only these can fail.)
155
- for (const attr of ['value', 'keywords']) {
156
- expect(copy.hasAttribute(attr)).toBe(false);
157
- }
158
- expect(option('settings').hasAttribute('disabled')).toBe(false);
159
- });
160
- // ── Handler composition ───────────────────────────────────────────────
161
- it('composes a consumer onclick with the built-in selection (consumer first)', () => {
162
- const seen = [];
163
- setup({
164
- itemAttrs: { onclick: () => seen.push('consumer') },
165
- onItemSelect: (v) => seen.push(`selected:${v}`),
166
- });
167
- option('copy').click();
168
- flushSync();
169
- expect(seen).toEqual(['consumer', 'selected:copy']);
170
- });
171
- it('lets a consumer onclick cancel the selection with preventDefault', () => {
172
- const selected = [];
173
- setup({
174
- itemAttrs: { onclick: (e) => e.preventDefault() },
175
- onItemSelect: (v) => selected.push(v),
176
- });
177
- // Move the selection away first, so a (wrongly) successful click is visible.
178
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
179
- flushSync();
180
- expect(option('paste').getAttribute('data-selected')).toBe('true');
181
- option('copy').click();
182
- flushSync();
183
- expect(selected).toEqual([]);
184
- expect(option('copy').getAttribute('data-selected')).toBe('false');
185
- expect(option('paste').getAttribute('data-selected')).toBe('true');
186
- });
187
- it('composes a consumer onkeydown with Enter on the option (consumer first)', () => {
188
- const seen = [];
189
- setup({
190
- itemAttrs: { onkeydown: () => seen.push('consumer') },
191
- onItemSelect: (v) => seen.push(`selected:${v}`),
192
- });
193
- option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
194
- flushSync();
195
- expect(seen).toEqual(['consumer', 'selected:copy']);
196
- });
197
- it('guard — composes a consumer onkeydown with Space on the option (consumer first)', () => {
198
- const seen = [];
199
- setup({
200
- itemAttrs: { onkeydown: () => seen.push('consumer') },
201
- onItemSelect: (v) => seen.push(`selected:${v}`),
202
- });
203
- option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
204
- flushSync();
205
- expect(seen).toEqual(['consumer', 'selected:copy']);
206
- });
207
- it('lets a consumer onkeydown cancel the Enter/Space activation with preventDefault', () => {
208
- const selected = [];
209
- setup({
210
- itemAttrs: { onkeydown: (e) => e.preventDefault() },
211
- onItemSelect: (v) => selected.push(v),
212
- });
213
- option('copy').dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
214
- flushSync();
215
- expect(selected).toEqual([]);
216
- });
217
- it('composes a consumer onpointermove with the hover-selection — which never fires onSelect', () => {
218
- const seen = [];
219
- setup({
220
- itemAttrs: { onpointermove: () => seen.push('consumer') },
221
- onItemSelect: (v) => seen.push(`selected:${v}`),
222
- });
223
- // Start from a different selection so the hover-select is observable.
224
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
225
- flushSync();
226
- expect(option('paste').getAttribute('data-selected')).toBe('true');
227
- option('copy').dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
228
- flushSync();
229
- expect(seen).toEqual(['consumer']);
230
- expect(option('copy').getAttribute('data-selected')).toBe('true');
231
- });
232
- it('lets a consumer onpointermove cancel the hover-selection with preventDefault', () => {
233
- setup({ itemAttrs: { onpointermove: (e) => e.preventDefault() } });
234
- input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
235
- flushSync();
236
- option('copy').dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
237
- flushSync();
238
- expect(option('copy').getAttribute('data-selected')).toBe('false');
239
- expect(option('paste').getAttribute('data-selected')).toBe('true');
240
- });
241
- // ── Disabled: activation inert, hover observable ──────────────────────
242
- it('guard — activation of a disabled item is inert: consumer onclick/onkeydown do not run', () => {
243
- const seen = [];
244
- setup({
245
- disabledItemAttrs: {
246
- onclick: () => seen.push('click'),
247
- onkeydown: () => seen.push('keydown'),
248
- },
249
- onDisabledItemSelect: (v) => seen.push(`selected:${v}`),
250
- });
251
- const settings = option('settings');
252
- settings.click();
253
- settings.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
254
- flushSync();
255
- expect(seen).toEqual([]);
256
- expect(option('copy').getAttribute('data-selected')).toBe('true');
257
- expect(settings.getAttribute('data-selected')).toBe('false');
258
- });
259
- it('still prevents the Space default on a focused disabled item, without running the consumer onkeydown', () => {
260
- const seen = [];
261
- setup({ disabledItemAttrs: { onkeydown: () => seen.push('keydown') } });
262
- const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
263
- option('settings').dispatchEvent(event);
264
- flushSync();
265
- // Pre-#205 the item prevented Enter/Space before bailing on `disabled`,
266
- // so Space on a dimmed, focused row never scrolled the list.
267
- expect(event.defaultPrevented).toBe(true);
268
- expect(seen).toEqual([]);
269
- expect(option('copy').getAttribute('data-selected')).toBe('true');
270
- });
271
- it('keeps a consumer onpointermove observable on a disabled item without selecting it', () => {
272
- const seen = [];
273
- setup({
274
- disabledItemAttrs: { onpointermove: () => seen.push('move') },
275
- onDisabledItemSelect: (v) => seen.push(`selected:${v}`),
276
- });
277
- const settings = option('settings');
278
- settings.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, cancelable: true }));
279
- flushSync();
280
- expect(seen).toEqual(['move']);
281
- expect(settings.getAttribute('data-selected')).toBe('false');
282
- expect(option('copy').getAttribute('data-selected')).toBe('true');
283
- });
284
- });
@@ -1,30 +0,0 @@
1
- import { mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
3
- import { configureAxe } from 'vitest-axe';
4
- import * as matchers from 'vitest-axe/matchers';
5
- import CommandTest from './CommandTest.svelte';
6
- expect.extend(matchers);
7
- const axe = configureAxe({
8
- rules: {
9
- region: { enabled: false },
10
- 'page-has-heading-one': { enabled: false },
11
- 'landmark-one-main': { enabled: false },
12
- // Command uses listbox > group > option — valid WAI-ARIA but axe doesn't walk through groups
13
- 'aria-required-children': { enabled: false },
14
- },
15
- });
16
- describe('command a11y', () => {
17
- let cleanup;
18
- afterEach(() => cleanup?.());
19
- it('has no axe violations', async () => {
20
- const container = document.createElement('div');
21
- document.body.appendChild(container);
22
- const instance = mount(CommandTest, { target: container });
23
- const results = await axe(container);
24
- cleanup = () => {
25
- unmount(instance);
26
- container.remove();
27
- };
28
- expect(results).toHaveNoViolations();
29
- });
30
- });