@spectral_labs/ui 1.7.0 → 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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/THIRD_PARTY_NOTICES.md +201 -0
  3. package/package.json +8 -2
  4. package/dist/components/__tests__/overlay-scrim-source.test.js +0 -134
  5. package/dist/components/accordion/__tests__/AccordionTest.svelte +0 -31
  6. package/dist/components/accordion/__tests__/accordion.test.svelte.js +0 -139
  7. package/dist/components/alert-dialog/__tests__/AlertDialogTest.svelte +0 -28
  8. package/dist/components/alert-dialog/__tests__/alert-dialog.a11y.test.js +0 -42
  9. package/dist/components/alert-dialog/__tests__/alert-dialog.integration.test.svelte.js +0 -51
  10. package/dist/components/alert-dialog/__tests__/alert-dialog.test.svelte.js +0 -113
  11. package/dist/components/app-bar/__tests__/app-bar.test.svelte.js +0 -30
  12. package/dist/components/aspect-ratio/__tests__/aspect-ratio.test.svelte.js +0 -22
  13. package/dist/components/avatar/__tests__/avatar.test.svelte.js +0 -26
  14. package/dist/components/badge/__tests__/badge.test.svelte.js +0 -30
  15. package/dist/components/bottom-sheet/__tests__/BottomSheetTest.svelte +0 -22
  16. package/dist/components/bottom-sheet/__tests__/bottom-sheet.test.svelte.js +0 -83
  17. package/dist/components/button/__tests__/button.test.svelte.js +0 -63
  18. package/dist/components/calendar/__tests__/CalendarTest.svelte +0 -56
  19. package/dist/components/calendar/__tests__/calendar.test.svelte.js +0 -190
  20. package/dist/components/card/__tests__/card.test.svelte.js +0 -30
  21. package/dist/components/checkbox/__tests__/checkbox.test.svelte.js +0 -44
  22. package/dist/components/chips/__tests__/chips.test.svelte.js +0 -26
  23. package/dist/components/collapsible/__tests__/CollapsibleTest.svelte +0 -18
  24. package/dist/components/collapsible/__tests__/collapsible.test.svelte.js +0 -125
  25. package/dist/components/combobox/__tests__/ComboboxTest.svelte +0 -40
  26. package/dist/components/combobox/__tests__/combobox.test.svelte.js +0 -255
  27. package/dist/components/command/__tests__/CommandInlineDirectTest.svelte +0 -29
  28. package/dist/components/command/__tests__/CommandInlineTest.svelte +0 -89
  29. package/dist/components/command/__tests__/CommandTest.svelte +0 -77
  30. package/dist/components/command/__tests__/command-inline.test.svelte.js +0 -392
  31. package/dist/components/command/__tests__/command-item-passthrough.test.svelte.js +0 -284
  32. package/dist/components/command/__tests__/command.a11y.test.js +0 -30
  33. package/dist/components/command/__tests__/command.integration.test.svelte.js +0 -68
  34. package/dist/components/command/__tests__/command.test.svelte.js +0 -230
  35. package/dist/components/context-menu/__tests__/ContextMenuNonInteractiveTest.svelte +0 -32
  36. package/dist/components/context-menu/__tests__/ContextMenuTest.svelte +0 -25
  37. package/dist/components/context-menu/__tests__/context-menu.test.svelte.js +0 -141
  38. package/dist/components/date-field/__tests__/DateFieldTest.svelte +0 -20
  39. package/dist/components/date-field/__tests__/date-field.test.svelte.js +0 -139
  40. package/dist/components/date-picker/__tests__/DatePickerTest.svelte +0 -20
  41. package/dist/components/date-picker/__tests__/date-picker.test.svelte.js +0 -190
  42. package/dist/components/date-range-field/__tests__/DateRangeFieldTest.svelte +0 -20
  43. package/dist/components/date-range-field/__tests__/date-range-field.test.svelte.js +0 -130
  44. package/dist/components/date-range-picker/__tests__/DateRangePickerTest.svelte +0 -18
  45. package/dist/components/date-range-picker/__tests__/date-range-picker.test.svelte.js +0 -156
  46. package/dist/components/dialog/__tests__/DialogTest.svelte +0 -34
  47. package/dist/components/dialog/__tests__/dialog.test.svelte.js +0 -184
  48. package/dist/components/drop-zone/__tests__/drop-zone.test.svelte.js +0 -26
  49. package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +0 -90
  50. package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +0 -71
  51. package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +0 -306
  52. package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +0 -567
  53. package/dist/components/empty-state/__tests__/empty-state.test.svelte.js +0 -26
  54. package/dist/components/fab/__tests__/fab.test.svelte.js +0 -30
  55. package/dist/components/field/__tests__/FieldTest.svelte +0 -26
  56. package/dist/components/field/__tests__/field.test.svelte.js +0 -74
  57. package/dist/components/grid-list/__tests__/GridListTest.svelte +0 -46
  58. package/dist/components/grid-list/__tests__/grid-list.test.svelte.js +0 -231
  59. package/dist/components/hover-card/__tests__/HoverCardTest.svelte +0 -30
  60. package/dist/components/hover-card/__tests__/hover-card.test.svelte.js +0 -236
  61. package/dist/components/indicator/__tests__/indicator.test.svelte.js +0 -30
  62. package/dist/components/input/__tests__/input.test.svelte.js +0 -67
  63. package/dist/components/label/__tests__/label.test.svelte.js +0 -22
  64. package/dist/components/language-select/__tests__/language-select.test.svelte.js +0 -199
  65. package/dist/components/language-select/__tests__/languages.test.js +0 -87
  66. package/dist/components/link-preview/__tests__/LinkPreviewTest.svelte +0 -37
  67. package/dist/components/link-preview/__tests__/link-preview.a11y.test.js +0 -28
  68. package/dist/components/link-preview/__tests__/link-preview.integration.test.svelte.js +0 -54
  69. package/dist/components/link-preview/__tests__/link-preview.test.svelte.js +0 -215
  70. package/dist/components/list/__tests__/list.test.svelte.js +0 -22
  71. package/dist/components/menubar/__tests__/MenubarTest.svelte +0 -52
  72. package/dist/components/menubar/__tests__/menubar.a11y.test.js +0 -28
  73. package/dist/components/menubar/__tests__/menubar.integration.test.svelte.js +0 -57
  74. package/dist/components/menubar/__tests__/menubar.test.svelte.js +0 -328
  75. package/dist/components/meter/__tests__/meter.test.svelte.js +0 -26
  76. package/dist/components/nav-bar/__tests__/nav-bar.test.svelte.js +0 -23
  77. package/dist/components/nav-drawer/__tests__/nav-drawer.test.svelte.js +0 -28
  78. package/dist/components/nav-rail/__tests__/nav-rail.test.svelte.js +0 -28
  79. package/dist/components/pagination/__tests__/pagination.test.svelte.js +0 -26
  80. package/dist/components/pin-input/__tests__/pin-input.test.svelte.js +0 -71
  81. package/dist/components/popover/__tests__/PopoverTest.svelte +0 -27
  82. package/dist/components/popover/__tests__/popover.test.svelte.js +0 -196
  83. package/dist/components/progress/__tests__/progress.test.svelte.js +0 -32
  84. package/dist/components/radio-group/__tests__/radio-group.test.svelte.js +0 -22
  85. package/dist/components/range-calendar/__tests__/RangeCalendarTest.svelte +0 -23
  86. package/dist/components/range-calendar/__tests__/range-calendar.test.svelte.js +0 -105
  87. package/dist/components/rating-group/__tests__/rating-group.test.svelte.js +0 -26
  88. package/dist/components/scroll-area/__tests__/scroll-area.test.svelte.js +0 -34
  89. package/dist/components/search-field/__tests__/SearchFieldTest.svelte +0 -34
  90. package/dist/components/search-field/__tests__/search-field.test.svelte.js +0 -118
  91. package/dist/components/select/__tests__/SelectLabelPersistenceTest.svelte +0 -31
  92. package/dist/components/select/__tests__/SelectTest.svelte +0 -33
  93. package/dist/components/select/__tests__/select.test.svelte.js +0 -221
  94. package/dist/components/separator/__tests__/separator.test.svelte.js +0 -39
  95. package/dist/components/sheet/__tests__/SheetTest.svelte +0 -34
  96. package/dist/components/sheet/__tests__/sheet.test.svelte.js +0 -193
  97. package/dist/components/side-sheet/__tests__/side-sheet.test.svelte.js +0 -138
  98. package/dist/components/sidebar/__tests__/SidebarFullTest.svelte +0 -45
  99. package/dist/components/sidebar/__tests__/SidebarTest.svelte +0 -9
  100. package/dist/components/sidebar/__tests__/sidebar.test.svelte.js +0 -186
  101. package/dist/components/simple-dialog/__tests__/SimpleDialogTest.svelte +0 -40
  102. package/dist/components/simple-dialog/__tests__/simple-dialog.test.svelte.js +0 -50
  103. package/dist/components/simple-tooltip/__tests__/SimpleTooltipTest.svelte +0 -19
  104. package/dist/components/simple-tooltip/__tests__/simple-tooltip.test.svelte.js +0 -61
  105. package/dist/components/slider/__tests__/slider.test.svelte.js +0 -42
  106. package/dist/components/sortable-list/__tests__/NestedSortableListTest.svelte +0 -49
  107. package/dist/components/sortable-list/__tests__/SortableListTest.svelte +0 -42
  108. package/dist/components/sortable-list/__tests__/sortable-list.nested.test.svelte.js +0 -112
  109. package/dist/components/sortable-list/__tests__/sortable-list.test.svelte.js +0 -175
  110. package/dist/components/splitter/__tests__/splitter.test.svelte.js +0 -26
  111. package/dist/components/switch/__tests__/switch.test.svelte.js +0 -44
  112. package/dist/components/table/__tests__/table.test.svelte.js +0 -22
  113. package/dist/components/tabs/__tests__/TabsTest.svelte +0 -32
  114. package/dist/components/tabs/__tests__/tabs.test.svelte.js +0 -227
  115. package/dist/components/textarea/__tests__/textarea.test.svelte.js +0 -55
  116. package/dist/components/time-field/__tests__/TimeFieldTest.svelte +0 -20
  117. package/dist/components/time-field/__tests__/time-field.test.svelte.js +0 -100
  118. package/dist/components/time-range-field/__tests__/TimeRangeFieldTest.svelte +0 -20
  119. package/dist/components/time-range-field/__tests__/time-range-field.test.svelte.js +0 -124
  120. package/dist/components/toast/__tests__/toast.test.svelte.js +0 -46
  121. package/dist/components/toggle/__tests__/toggle.test.svelte.js +0 -38
  122. package/dist/components/toggle-group/__tests__/toggle-group.test.svelte.js +0 -22
  123. package/dist/components/toolbar/__tests__/ToolbarCompositionTest.svelte +0 -18
  124. package/dist/components/toolbar/__tests__/ToolbarTabstopTest.svelte +0 -10
  125. package/dist/components/toolbar/__tests__/toolbar.test.svelte.js +0 -63
  126. package/dist/components/tooltip/__tests__/TooltipTest.svelte +0 -22
  127. package/dist/components/tooltip/__tests__/tooltip.test.svelte.js +0 -225
  128. package/dist/components/tree/__tests__/TreeLabelContentTest.svelte +0 -21
  129. package/dist/components/tree/__tests__/TreeTest.svelte +0 -24
  130. package/dist/components/tree/__tests__/tree.test.svelte.js +0 -229
  131. package/dist/components/virtualizer/__tests__/waterfall-layout.test.js +0 -82
  132. package/dist/internal/announcer/__tests__/announcer.test.js +0 -42
  133. package/dist/internal/dismissible-layer/__tests__/dismissible-layer.test.js +0 -196
  134. package/dist/internal/drag-drop/__tests__/auto-scroll.test.js +0 -104
  135. package/dist/internal/drag-drop/__tests__/drag-session.test.svelte.js +0 -803
  136. package/dist/internal/drag-drop/__tests__/drag-source.test.svelte.js +0 -130
  137. package/dist/internal/drag-drop/__tests__/drop-delegate.test.js +0 -223
  138. package/dist/internal/drag-drop/__tests__/types.test.js +0 -20
  139. package/dist/internal/escape-layer/__tests__/escape-layer.test.js +0 -113
  140. package/dist/internal/focus-scope/__tests__/focus-scope.test.js +0 -225
  141. package/dist/internal/grace-area/__tests__/grace-area.test.js +0 -67
  142. package/dist/internal/helpers/__tests__/helpers.test.js +0 -123
  143. package/dist/internal/portal/__tests__/PortalTest.svelte +0 -21
  144. package/dist/internal/portal/__tests__/portal.test.js +0 -29
  145. package/dist/internal/portal/__tests__/portal.test.svelte.js +0 -57
  146. package/dist/internal/scroll-lock/__tests__/scroll-lock.test.js +0 -160
  147. package/dist/internal/typeahead/__tests__/typeahead.test.js +0 -61
  148. package/src/themes/__tests__/carbon-dark-contrast.test.ts +0 -170
  149. package/src/themes/__tests__/generated-css-drift.test.ts +0 -53
  150. package/src/themes/__tests__/scrim-opaque.test.ts +0 -52
@@ -1,567 +0,0 @@
1
- import { flushSync, mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import DropdownMenuRoot from '../components/dropdown-menu-root.svelte';
4
- import DropdownMenuTest from './DropdownMenuTest.svelte';
5
- describe('dropdown-menu', () => {
6
- let target;
7
- let instance;
8
- function setup(props = {}) {
9
- target = document.createElement('div');
10
- document.body.appendChild(target);
11
- instance = mount(DropdownMenuTest, { 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(DropdownMenuRoot, { target, props });
19
- flushSync();
20
- return target;
21
- }
22
- afterEach(() => {
23
- if (instance)
24
- unmount(instance);
25
- target?.remove();
26
- });
27
- it('renders closed by default', () => {
28
- setupRoot();
29
- const root = target.querySelector('.sp-dropdown-menu-root');
30
- expect(root).toBeTruthy();
31
- expect(root?.getAttribute('data-state')).toBe('closed');
32
- });
33
- it('content not rendered when closed', () => {
34
- setup();
35
- expect(target.querySelector('[role="menu"]')).toBeNull();
36
- });
37
- it('content renders when open', () => {
38
- setup({ open: true });
39
- expect(target.querySelector('[role="menu"]')).toBeTruthy();
40
- });
41
- it('calls onOpenChange with new value on each open transition', () => {
42
- const calls = [];
43
- const localTarget = document.createElement('div');
44
- document.body.appendChild(localTarget);
45
- instance = mount(DropdownMenuTest, {
46
- target: localTarget,
47
- props: {
48
- open: false,
49
- portalTo: localTarget,
50
- onOpenChange: (open) => calls.push(open),
51
- },
52
- });
53
- target = localTarget;
54
- flushSync();
55
- const trigger = localTarget.querySelector('.sp-dropdown-menu-trigger');
56
- trigger.click();
57
- flushSync();
58
- expect(calls).toEqual([true]);
59
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
60
- flushSync();
61
- expect(calls).toEqual([true, false]);
62
- });
63
- describe('subTrigger', () => {
64
- function pressKey(key) {
65
- const el = document.activeElement;
66
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
67
- flushSync();
68
- }
69
- it('participates in roving focus (data-roving-focus-item + tabindex -1)', () => {
70
- setup({ open: true, withSub: true });
71
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
72
- expect(subTrigger).toBeTruthy();
73
- expect(subTrigger.hasAttribute('data-roving-focus-item')).toBe(true);
74
- expect(subTrigger.getAttribute('tabindex')).toBe('-1');
75
- });
76
- it('carries both item and sub-trigger classes (visual parity hook)', () => {
77
- setup({ open: true, withSub: true });
78
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
79
- expect(subTrigger.classList.contains('sp-dropdown-menu-item')).toBe(true);
80
- expect(subTrigger.classList.contains('sp-dropdown-sub-trigger')).toBe(true);
81
- });
82
- it('is autofocused on open when it is the first row', () => {
83
- setup({ open: true, withSub: true, subFirst: true });
84
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
85
- expect(document.activeElement).toBe(subTrigger);
86
- });
87
- it('is reached by ArrowDown/ArrowUp roving among plain items', () => {
88
- setup({ open: true, withSub: true });
89
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
90
- // Autofocus lands on Item 1; sub-trigger is the 4th row.
91
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
92
- pressKey('ArrowDown');
93
- pressKey('ArrowDown');
94
- pressKey('ArrowDown');
95
- expect(document.activeElement).toBe(subTrigger);
96
- pressKey('ArrowUp');
97
- expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
98
- });
99
- it('is skipped by autofocus and roving when disabled', () => {
100
- setup({ open: true, withSub: true, subFirst: true, subDisabled: true });
101
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
102
- expect(subTrigger.hasAttribute('data-disabled')).toBe(true);
103
- // Autofocus skips the disabled sub-trigger even though it is first.
104
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
105
- // Roving loops over the three plain items without ever landing on it.
106
- pressKey('ArrowDown');
107
- pressKey('ArrowDown');
108
- pressKey('ArrowDown');
109
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
110
- pressKey('ArrowUp');
111
- expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
112
- });
113
- it('opens the sub content on ArrowRight and reflects aria-expanded', () => {
114
- setup({ open: true, withSub: true, subFirst: true });
115
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
116
- expect(document.activeElement).toBe(subTrigger);
117
- expect(subTrigger.getAttribute('aria-expanded')).toBe('false');
118
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
119
- pressKey('ArrowRight');
120
- expect(subTrigger.getAttribute('aria-expanded')).toBe('true');
121
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
122
- });
123
- it('opens the sub content on Enter', () => {
124
- setup({ open: true, withSub: true, subFirst: true });
125
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
126
- pressKey('Enter');
127
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
128
- });
129
- it('renders textValue as data-text-value (typeahead escape hatch)', () => {
130
- setup({ open: true, withSub: true, subTextValue: 'Share' });
131
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
132
- expect(subTrigger.getAttribute('data-text-value')).toBe('Share');
133
- });
134
- it('is wired to the sub content via aria-controls / aria-labelledby', () => {
135
- setup({ open: true, withSub: true });
136
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
137
- // Closed: no aria-controls (same conditional plumbing as the root trigger).
138
- expect(subTrigger.hasAttribute('aria-controls')).toBe(false);
139
- subTrigger.click();
140
- flushSync();
141
- const subContent = target.querySelector('.sp-dropdown-menu-sub-content');
142
- expect(subTrigger.id).toBeTruthy();
143
- expect(subContent.id).toBeTruthy();
144
- expect(subTrigger.getAttribute('aria-controls')).toBe(subContent.id);
145
- expect(subContent.getAttribute('aria-labelledby')).toBe(subTrigger.id);
146
- });
147
- it('omits data-text-value when textValue is not provided', () => {
148
- setup({ open: true, withSub: true });
149
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
150
- expect(subTrigger.hasAttribute('data-text-value')).toBe(false);
151
- });
152
- });
153
- describe('sub keyboard model (focus-into-sub + restore)', () => {
154
- function pressKey(key) {
155
- const el = document.activeElement;
156
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
157
- flushSync();
158
- }
159
- it('keyboard open (ArrowRight) moves focus to the first sub item', () => {
160
- setup({ open: true, withSub: true, subFirst: true });
161
- // Autofocus lands on the sub trigger (first row).
162
- pressKey('ArrowRight');
163
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
164
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
165
- });
166
- it('keyboard open (Enter) moves focus to the first sub item', () => {
167
- setup({ open: true, withSub: true, subFirst: true });
168
- pressKey('Enter');
169
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
170
- });
171
- it('pointer open does NOT steal focus (APG)', () => {
172
- vi.useFakeTimers();
173
- try {
174
- setup({ open: true, withSub: true });
175
- // Autofocus lands on Item 1 (sub trigger is the last row).
176
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
177
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
178
- subTrigger.dispatchEvent(new MouseEvent('pointerenter', { clientX: 10, clientY: 10 }));
179
- flushSync();
180
- // Hover-open still waits the 150ms delay…
181
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
182
- vi.advanceTimersByTime(150);
183
- flushSync();
184
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
185
- // …and focus stayed where it was.
186
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
187
- }
188
- finally {
189
- vi.useRealTimers();
190
- }
191
- });
192
- it('arrowLeft inside the sub closes it and restores focus to the trigger', () => {
193
- setup({ open: true, withSub: true, subFirst: true });
194
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
195
- pressKey('ArrowRight');
196
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
197
- pressKey('ArrowLeft');
198
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
199
- // Focus restored to the trigger row — no dead end on document.body.
200
- expect(document.activeElement).toBe(subTrigger);
201
- // The parent menu stayed open.
202
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeTruthy();
203
- });
204
- it('escape inside the sub closes ONLY the sub and restores focus', () => {
205
- setup({ open: true, withSub: true, subFirst: true });
206
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
207
- pressKey('ArrowRight');
208
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
209
- pressKey('Escape');
210
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
211
- expect(document.activeElement).toBe(subTrigger);
212
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeTruthy();
213
- // A second Escape (sub layer unmounted) closes the whole menu.
214
- pressKey('Escape');
215
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeNull();
216
- });
217
- it('sub content has its own ArrowUp/Down/Home/End roving over sub items', () => {
218
- setup({ open: true, withSub: true, subFirst: true });
219
- pressKey('ArrowRight');
220
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
221
- pressKey('ArrowDown');
222
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 2');
223
- // Loops within the sub — never escapes into the parent collection.
224
- pressKey('ArrowDown');
225
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
226
- pressKey('ArrowUp');
227
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 2');
228
- pressKey('Home');
229
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
230
- pressKey('End');
231
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 2');
232
- });
233
- it('parent roving skips sub items while the sub is open', () => {
234
- setup({ open: true, withSub: true, subFirst: true });
235
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
236
- // Pointer open (click) keeps focus on the trigger.
237
- subTrigger.click();
238
- flushSync();
239
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
240
- expect(document.activeElement).toBe(subTrigger);
241
- // ArrowDown from the sub trigger goes to the next PARENT item,
242
- // not into the open sub.
243
- pressKey('ArrowDown');
244
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
245
- });
246
- it('parent typeahead ignores sub items while the sub is open', () => {
247
- setup({ open: true, withSub: true, subFirst: true });
248
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
249
- subTrigger.click();
250
- flushSync();
251
- expect(document.activeElement).toBe(subTrigger);
252
- // 's' would match 'Sub Item 1' if sub rows leaked into the parent
253
- // collection; with scoping there is no parent match, focus stays put.
254
- pressKey('s');
255
- expect(document.activeElement).toBe(subTrigger);
256
- });
257
- });
258
- describe('sub pointer model (sibling hover close + grace area)', () => {
259
- function openSubWithPointer() {
260
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
261
- subTrigger.click();
262
- flushSync();
263
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
264
- return subTrigger;
265
- }
266
- function mockRect(el, rect) {
267
- el.getBoundingClientRect = () => ({
268
- x: rect.left ?? 0,
269
- y: rect.top ?? 0,
270
- top: rect.top ?? 0,
271
- left: rect.left ?? 0,
272
- right: rect.right ?? 0,
273
- bottom: rect.bottom ?? 0,
274
- width: (rect.right ?? 0) - (rect.left ?? 0),
275
- height: (rect.bottom ?? 0) - (rect.top ?? 0),
276
- toJSON: () => ({}),
277
- });
278
- }
279
- it('pointerenter on a sibling plain item closes the open sub', () => {
280
- setup({ open: true, withSub: true });
281
- openSubWithPointer();
282
- const item = Array.from(target.querySelectorAll('.sp-dropdown-menu-content .sp-dropdown-menu-item')).find((el) => el.textContent?.trim() === 'Item 1');
283
- item.dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
284
- flushSync();
285
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
286
- // The parent menu itself stayed open.
287
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeTruthy();
288
- });
289
- it('pointerenter on a sibling checkbox item closes the open sub', () => {
290
- setup({ open: true, withSub: true, withCheckbox: true });
291
- openSubWithPointer();
292
- const checkbox = target.querySelector('[role="menuitemcheckbox"]');
293
- checkbox.dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
294
- flushSync();
295
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
296
- });
297
- it('pointerenter on a sub item (inside the sub) does NOT close it', () => {
298
- setup({ open: true, withSub: true });
299
- openSubWithPointer();
300
- const subItem = Array.from(target.querySelectorAll('.sp-dropdown-menu-sub-content .sp-dropdown-menu-item')).find((el) => el.textContent?.trim() === 'Sub Item 1');
301
- subItem.dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
302
- flushSync();
303
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
304
- });
305
- it('closes when the pointer leaves the trigger away from the content', () => {
306
- setup({ open: true, withSub: true });
307
- const subTrigger = openSubWithPointer();
308
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 50, clientY: 50 }));
309
- flushSync();
310
- // Still open while nothing else happened…
311
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
312
- // …but a pointermove outside trigger, content and polygon closes it.
313
- document.body.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400, bubbles: true }));
314
- flushSync();
315
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
316
- });
317
- it('stays open while the pointer travels through the grace polygon (mocked rects)', () => {
318
- setup({ open: true, withSub: true });
319
- const subTrigger = openSubWithPointer();
320
- const subContent = target.querySelector('.sp-dropdown-menu-sub-content');
321
- // Content sits to the right of the trigger.
322
- mockRect(subContent, { left: 100, right: 280, top: 0, bottom: 100 });
323
- // Exit the trigger at (90, 50), heading toward the content.
324
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 90, clientY: 50 }));
325
- flushSync();
326
- // Diagonal travel inside the polygon [exit → content top-left → content bottom-left].
327
- document.body.dispatchEvent(new MouseEvent('pointermove', { clientX: 95, clientY: 50, bubbles: true }));
328
- flushSync();
329
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
330
- // A sibling item under the diagonal is entered mid-travel: suppressed by the grace area.
331
- const item = Array.from(target.querySelectorAll('.sp-dropdown-menu-content .sp-dropdown-menu-item')).find((el) => el.textContent?.trim() === 'Item 1');
332
- item.dispatchEvent(new MouseEvent('pointerenter', { clientX: 96, clientY: 55 }));
333
- flushSync();
334
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
335
- // Reaching the content resolves the travel — the sub stays open.
336
- subContent.dispatchEvent(new MouseEvent('pointermove', { clientX: 150, clientY: 50, bubbles: true }));
337
- flushSync();
338
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
339
- // Grace tracking resolved: entering a sibling now closes the sub.
340
- item.dispatchEvent(new MouseEvent('pointerenter', { clientX: 50, clientY: 55 }));
341
- flushSync();
342
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
343
- });
344
- it('closes via the safety timeout when the travel never resolves', () => {
345
- vi.useFakeTimers();
346
- try {
347
- setup({ open: true, withSub: true });
348
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
349
- subTrigger.click();
350
- flushSync();
351
- const subContent = target.querySelector('.sp-dropdown-menu-sub-content');
352
- mockRect(subContent, { left: 100, right: 280, top: 0, bottom: 100 });
353
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 90, clientY: 50 }));
354
- flushSync();
355
- // Pointer stalls inside the polygon…
356
- document.body.dispatchEvent(new MouseEvent('pointermove', { clientX: 95, clientY: 50, bubbles: true }));
357
- flushSync();
358
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
359
- // …and the 500ms safety timeout closes the sub (tooltip/link-preview parity).
360
- vi.advanceTimersByTime(500);
361
- flushSync();
362
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
363
- }
364
- finally {
365
- vi.useRealTimers();
366
- }
367
- });
368
- it('re-entering the trigger cancels the grace close tracking', () => {
369
- setup({ open: true, withSub: true });
370
- const subTrigger = openSubWithPointer();
371
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 50, clientY: 50 }));
372
- flushSync();
373
- // Pointer comes back on the trigger before leaving the polygon.
374
- subTrigger.dispatchEvent(new MouseEvent('pointerenter', { clientX: 51, clientY: 50 }));
375
- flushSync();
376
- // A later far-away pointermove no longer closes anything (tracking stopped).
377
- document.body.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400, bubbles: true }));
378
- flushSync();
379
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
380
- });
381
- });
382
- describe('sub mixed modality (pointer close while keyboard focus is inside)', () => {
383
- function pressKey(key) {
384
- const el = document.activeElement;
385
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
386
- flushSync();
387
- }
388
- function findParentItem(label) {
389
- return Array.from(target.querySelectorAll('.sp-dropdown-menu-content .sp-dropdown-menu-item')).find((el) => el.textContent?.trim() === label);
390
- }
391
- it('sibling hover close restores focus to the trigger — keyboard stays alive', () => {
392
- setup({ open: true, withSub: true, subFirst: true });
393
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
394
- // Keyboard open: focus lands inside the sub.
395
- pressKey('ArrowRight');
396
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
397
- // The mouse resting over the menu drifts onto a sibling row (grace is
398
- // unarmed — the trigger never saw a pointerleave).
399
- findParentItem('Item 1').dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
400
- flushSync();
401
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
402
- // Focus restored to the trigger — never orphaned on document.body.
403
- expect(document.activeElement).toBe(subTrigger);
404
- // Parent roving still works from there (no dead end).
405
- pressKey('ArrowDown');
406
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
407
- });
408
- it('grace-exit close restores focus to the trigger', () => {
409
- setup({ open: true, withSub: true, subFirst: true });
410
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
411
- pressKey('ArrowRight');
412
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
413
- // The pointer brushes across the trigger, then leaves away from the content.
414
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 50, clientY: 50 }));
415
- flushSync();
416
- document.body.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400, bubbles: true }));
417
- flushSync();
418
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
419
- expect(document.activeElement).toBe(subTrigger);
420
- });
421
- it('safety-timeout close restores focus to the trigger', () => {
422
- vi.useFakeTimers();
423
- try {
424
- setup({ open: true, withSub: true, subFirst: true });
425
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
426
- pressKey('ArrowRight');
427
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
428
- // Grace tracking armed but never resolved: the 500ms safety timer closes.
429
- subTrigger.dispatchEvent(new MouseEvent('pointerleave', { clientX: 50, clientY: 50 }));
430
- flushSync();
431
- vi.advanceTimersByTime(500);
432
- flushSync();
433
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
434
- expect(document.activeElement).toBe(subTrigger);
435
- }
436
- finally {
437
- vi.useRealTimers();
438
- }
439
- });
440
- it('pointer close does NOT move focus when focus is outside the sub', () => {
441
- setup({ open: true, withSub: true });
442
- // Autofocus lands on Item 1; pointer open (click) never steals focus.
443
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
444
- subTrigger.click();
445
- flushSync();
446
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
447
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
448
- findParentItem('Item 2').dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
449
- flushSync();
450
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
451
- // Pure pointer flow: focus stays put, the trigger does not grab it.
452
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
453
- });
454
- });
455
- describe('sub escape scoping (APG: Escape closes the menu containing focus)', () => {
456
- function pressKey(key) {
457
- const el = document.activeElement;
458
- el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
459
- flushSync();
460
- }
461
- it('escape with focus in the PARENT menu and a hover-opened sub closes the whole menu in ONE keypress, without focus teleport', () => {
462
- setup({ open: true, withSub: true });
463
- // Autofocus lands on Item 1 (parent row); pointer open never steals focus.
464
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
465
- subTrigger.click();
466
- flushSync();
467
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
468
- expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
469
- // ONE Escape: the sub layer defers (focus is NOT inside the sub) and the
470
- // parent layer closes the whole menu — sub included, no warp to the sub
471
- // trigger mid-keypress.
472
- pressKey('Escape');
473
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
474
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeNull();
475
- expect(target.querySelector('.sp-dropdown-menu-root')?.getAttribute('data-state')).toBe('closed');
476
- });
477
- it('escape with focus INSIDE the sub still closes only the sub (existing behavior preserved)', () => {
478
- setup({ open: true, withSub: true, subFirst: true });
479
- const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
480
- pressKey('ArrowRight');
481
- expect(document.activeElement?.textContent?.trim()).toBe('Sub Item 1');
482
- pressKey('Escape');
483
- expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
484
- expect(document.activeElement).toBe(subTrigger);
485
- expect(target.querySelector('.sp-dropdown-menu-content')).toBeTruthy();
486
- });
487
- });
488
- describe('single open sub per level', () => {
489
- function subContents() {
490
- return Array.from(target.querySelectorAll('.sp-dropdown-menu-sub-content'));
491
- }
492
- function findSubTrigger(label) {
493
- return Array.from(target.querySelectorAll('.sp-dropdown-sub-trigger')).find((el) => el.textContent?.includes(label));
494
- }
495
- function mockRect(el, rect) {
496
- el.getBoundingClientRect = () => ({
497
- x: rect.left ?? 0,
498
- y: rect.top ?? 0,
499
- top: rect.top ?? 0,
500
- left: rect.left ?? 0,
501
- right: rect.right ?? 0,
502
- bottom: rect.bottom ?? 0,
503
- width: (rect.right ?? 0) - (rect.left ?? 0),
504
- height: (rect.bottom ?? 0) - (rect.top ?? 0),
505
- toJSON: () => ({}),
506
- });
507
- }
508
- it('keyboard open on a sibling sub trigger closes the hover-opened sub', () => {
509
- setup({ open: true, withSub: true, withSecondSub: true });
510
- const trigA = findSubTrigger('More options');
511
- const trigB = findSubTrigger('Other options');
512
- // Pointer path opens sub A.
513
- trigA.click();
514
- flushSync();
515
- expect(subContents().length).toBe(1);
516
- expect(subContents()[0].textContent).toContain('Sub Item 1');
517
- // Keyboard open of sibling B (never goes through onItemEnter): A must
518
- // close — one open sub per level.
519
- trigB.focus();
520
- trigB.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
521
- flushSync();
522
- const open = subContents();
523
- expect(open.length).toBe(1);
524
- expect(open[0].textContent).toContain('Sub B Item 1');
525
- // Keyboard open still moves focus into B (APG).
526
- expect(document.activeElement?.textContent?.trim()).toBe('Sub B Item 1');
527
- });
528
- it('hover-open fired from inside a sibling grace polygon closes the traveled sub (and its safety timer)', () => {
529
- vi.useFakeTimers();
530
- try {
531
- setup({ open: true, withSub: true, withSecondSub: true });
532
- const trigA = findSubTrigger('More options');
533
- const trigB = findSubTrigger('Other options');
534
- trigA.click();
535
- flushSync();
536
- const contentA = subContents()[0];
537
- // A's content sits to the right of its trigger.
538
- mockRect(contentA, { left: 100, right: 280, top: 0, bottom: 100 });
539
- // Grace travel armed from A's trigger toward its content.
540
- trigA.dispatchEvent(new MouseEvent('pointerleave', { clientX: 90, clientY: 50 }));
541
- flushSync();
542
- // B's row sits under the diagonal: its pointerenter is spared by the
543
- // grace check (A stays open), but B's hover timer still arms.
544
- trigB.dispatchEvent(new MouseEvent('pointerenter', { clientX: 95, clientY: 55 }));
545
- flushSync();
546
- expect(subContents().length).toBe(1);
547
- expect(subContents()[0].textContent).toContain('Sub Item 1');
548
- // The pointer stalls on B: the 150ms hover-open fires and must
549
- // force-close A — never two open subs at the same level.
550
- vi.advanceTimersByTime(150);
551
- flushSync();
552
- const open = subContents();
553
- expect(open.length).toBe(1);
554
- expect(open[0].textContent).toContain('Sub B Item 1');
555
- // A's grace safety timer was cancelled by its close: 500ms later
556
- // nothing closes B.
557
- vi.advanceTimersByTime(500);
558
- flushSync();
559
- expect(subContents().length).toBe(1);
560
- expect(subContents()[0].textContent).toContain('Sub B Item 1');
561
- }
562
- finally {
563
- vi.useRealTimers();
564
- }
565
- });
566
- });
567
- });
@@ -1,26 +0,0 @@
1
- import { mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
3
- import EmptyState from '../empty-state.svelte';
4
- describe('empty-state', () => {
5
- let target;
6
- let instance;
7
- function setup(props = {}) {
8
- target = document.createElement('div');
9
- document.body.appendChild(target);
10
- instance = mount(EmptyState, { target, props });
11
- return target;
12
- }
13
- afterEach(() => {
14
- if (instance)
15
- unmount(instance);
16
- target?.remove();
17
- });
18
- it('renders with role=status', () => {
19
- setup();
20
- expect(target.querySelector('[role="status"]')).toBeTruthy();
21
- });
22
- it('shows title', () => {
23
- setup({ title: 'No data' });
24
- expect(target.querySelector('.sp-empty-state-title')?.textContent).toBe('No data');
25
- });
26
- });
@@ -1,30 +0,0 @@
1
- import { mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
3
- import Fab from '../fab.svelte';
4
- describe('fab', () => {
5
- let target;
6
- let instance;
7
- function setup(props = {}) {
8
- target = document.createElement('div');
9
- document.body.appendChild(target);
10
- instance = mount(Fab, { target, props });
11
- return target;
12
- }
13
- afterEach(() => {
14
- if (instance)
15
- unmount(instance);
16
- target?.remove();
17
- });
18
- it('renders button', () => {
19
- setup();
20
- expect(target.querySelector('button')).toBeTruthy();
21
- });
22
- it('applies variant', () => {
23
- setup({ variant: 'tonal' });
24
- expect(target.querySelector('.sp-fab')?.className.includes('tonal')).toBeTruthy();
25
- });
26
- it('applies size', () => {
27
- setup({ size: 'lg' });
28
- expect(target.querySelector('.sp-fab')?.className.includes('lg')).toBeTruthy();
29
- });
30
- });
@@ -1,26 +0,0 @@
1
- <script lang="ts">
2
- import * as Field from '../index.js'
3
-
4
- const {
5
- error = undefined as string | undefined,
6
- description = 'Help text',
7
- id = 'my-field',
8
- required = false,
9
- }: {
10
- error?: string
11
- description?: string
12
- id?: string
13
- required?: boolean
14
- } = $props()
15
- </script>
16
-
17
- <Field.Root>
18
- <Field.Label for={id} {required}>Label text</Field.Label>
19
- <input {id} type="text" data-testid="input" />
20
- {#if description}
21
- <Field.Description>{description}</Field.Description>
22
- {/if}
23
- {#if error}
24
- <Field.Error>{error}</Field.Error>
25
- {/if}
26
- </Field.Root>