@spectral_labs/ui 1.5.2 → 1.6.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 (39) hide show
  1. package/dist/components/__tests__/overlay-scrim-source.test.js +134 -0
  2. package/dist/components/command/__tests__/CommandTest.svelte +24 -2
  3. package/dist/components/command/__tests__/command-item-passthrough.test.svelte.js +284 -0
  4. package/dist/components/command/components/command-item.svelte +49 -12
  5. package/dist/components/command/components/command-item.svelte.d.ts.map +1 -1
  6. package/dist/components/command/types.d.ts +27 -2
  7. package/dist/components/command/types.d.ts.map +1 -1
  8. package/dist/components/context-menu/index.d.ts +3 -0
  9. package/dist/components/context-menu/index.d.ts.map +1 -1
  10. package/dist/components/context-menu/index.js +2 -0
  11. package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +90 -0
  12. package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +306 -0
  13. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte +45 -0
  14. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts +5 -0
  15. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts.map +1 -0
  16. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte +143 -0
  17. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts +5 -0
  18. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts.map +1 -0
  19. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts +18 -0
  20. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts.map +1 -1
  21. package/dist/components/dropdown-menu/dropdown-menu.svelte.js +16 -0
  22. package/dist/components/dropdown-menu/index.d.ts +3 -1
  23. package/dist/components/dropdown-menu/index.d.ts.map +1 -1
  24. package/dist/components/dropdown-menu/index.js +2 -0
  25. package/dist/components/dropdown-menu/types.d.ts +35 -1
  26. package/dist/components/dropdown-menu/types.d.ts.map +1 -1
  27. package/dist/components/menubar/index.d.ts +3 -1
  28. package/dist/components/menubar/index.d.ts.map +1 -1
  29. package/dist/components/menubar/index.js +2 -0
  30. package/package.json +1 -1
  31. package/src/themes/__tests__/generated-css-drift.test.ts +53 -0
  32. package/src/themes/__tests__/scrim-opaque.test.ts +52 -0
  33. package/src/themes/carbon.css +1 -2
  34. package/src/themes/day.css +1 -2
  35. package/src/themes/docs.css +1 -1
  36. package/src/themes/iris-ocr.css +1 -2
  37. package/src/themes/midnight.css +1 -2
  38. package/src/themes/spectral.css +1 -3
  39. package/src/tokens.css +1 -1
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ /**
6
+ * spectral-ui#206 — the six covering surfaces paint the OPAQUE scrim token and
7
+ * own the veil's alpha through `opacity` (0.32, via the component's
8
+ * `--sp-comp-*-overlay-opacity` token where one exists). Exactly one owner:
9
+ * the colour is never translucent, the opacity is never missing or wrong.
10
+ *
11
+ * The two values are matched against an ALLOW-LIST, not a deny-list: any other
12
+ * spelling fails, so `#0000004d`, `oklch(0 0 0 / .3)`, `rgb(0 0 0 / 30%)` or a
13
+ * `color-mix()` cannot slip through a list of forbidden prefixes. Declarations
14
+ * are parsed (property, value) instead of substring-matched, because
15
+ * `--sp-comp-dialog-overlay-opacity: 0.32;` literally ends with
16
+ * `opacity: 0.32;` and would otherwise satisfy a naive regex while the real
17
+ * `opacity` declaration says something else.
18
+ *
19
+ * EVERY rule in the file that targets the class is inspected, not just the
20
+ * first one: in a Svelte component a later rule (a duplicate, a `@media`
21
+ * block) lives in the same scoped `<style>` and wins the cascade.
22
+ *
23
+ * Three of these surfaces (SideSheet, NavDrawer, BottomSheet) have no cell in
24
+ * `tests/e2e/scrim-alpha.spec.ts`, so this file is their only control — it has
25
+ * to be exact. The list is literal on purpose: a seventh surface that forgets
26
+ * the idiom has to be added here.
27
+ */
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const SURFACES = [
30
+ { file: 'dialog/components/dialog-overlay.svelte', selector: '.sp-dialog-overlay' },
31
+ {
32
+ file: 'alert-dialog/components/alert-dialog-overlay.svelte',
33
+ selector: '.sp-alert-dialog-overlay',
34
+ },
35
+ { file: 'sheet/components/sheet-overlay.svelte', selector: '.sp-sheet-overlay' },
36
+ { file: 'side-sheet/side-sheet.svelte', selector: '.sp-side-sheet-overlay' },
37
+ { file: 'nav-drawer/components/nav-drawer-root.svelte', selector: '.sp-nav-drawer-overlay' },
38
+ {
39
+ file: 'bottom-sheet/components/bottom-sheet-root.svelte',
40
+ selector: '.sp-bottom-sheet-overlay',
41
+ },
42
+ ];
43
+ /** `background-color` values that keep the colour opaque and token-driven. */
44
+ const ALLOWED_BACKGROUND = [
45
+ /^var\(--sp-sys-color-scrim\)$/,
46
+ /^var\(--sp-comp-[a-z-]+-overlay-color,\s*var\(--sp-sys-color-scrim\)\)$/,
47
+ ];
48
+ /** `opacity` values that make the component the single owner of the alpha. */
49
+ const ALLOWED_OPACITY = [/^0\.32$/, /^var\(--sp-comp-[a-z-]+-overlay-opacity,\s*0\.32\)$/];
50
+ /** The component's scoped stylesheet, so template braces are never parsed. */
51
+ function styleBlock(componentSource, file) {
52
+ const match = componentSource.match(/<style>(.*)<\/style>/s);
53
+ if (!match)
54
+ throw new Error(`${file}: no <style> block`);
55
+ return match[1];
56
+ }
57
+ /** Every rule in `source` whose selector list mentions `selector`, in order. */
58
+ function rulesTargeting(source, selector) {
59
+ const rules = [];
60
+ for (let open = 0; open < source.length; open++) {
61
+ if (source[open] !== '{')
62
+ continue;
63
+ let start = open - 1;
64
+ while (start >= 0 && !'{};'.includes(source[start]))
65
+ start--;
66
+ const selectorList = source.slice(start + 1, open).trim();
67
+ if (!selectorList.includes(selector))
68
+ continue;
69
+ let depth = 1;
70
+ let close = open + 1;
71
+ for (; close < source.length && depth > 0; close++) {
72
+ if (source[close] === '{')
73
+ depth++;
74
+ else if (source[close] === '}')
75
+ depth--;
76
+ }
77
+ const declarations = source
78
+ .slice(open + 1, close - 1)
79
+ .split(';')
80
+ .map((chunk) => chunk.trim())
81
+ .filter((chunk) => chunk.includes(':') && !chunk.includes('{'))
82
+ .map((chunk) => ({
83
+ property: chunk.slice(0, chunk.indexOf(':')).trim(),
84
+ value: chunk.slice(chunk.indexOf(':') + 1).trim(),
85
+ }));
86
+ rules.push({ selectorList, declarations });
87
+ }
88
+ return rules;
89
+ }
90
+ const valueOf = (rule, property) => rule.declarations.findLast((d) => d.property === property)?.value;
91
+ describe('overlay surfaces own the scrim alpha through opacity (#206)', () => {
92
+ for (const { file, selector } of SURFACES) {
93
+ const source = readFileSync(resolve(here, '..', file), 'utf8');
94
+ const rules = rulesTargeting(styleBlock(source, file), selector);
95
+ // The veil rule is the one whose selector is the bare class; anything else
96
+ // (`[data-state='closed']`, a media-query variant) is a variant rule.
97
+ const veilRules = rules.filter((r) => r.selectorList === selector);
98
+ const variantRules = rules.filter((r) => r.selectorList !== selector);
99
+ it(`${selector} is painted by exactly one veil rule`, () => {
100
+ expect(veilRules).toHaveLength(1);
101
+ });
102
+ it(`${selector} paints the opaque scrim token`, () => {
103
+ const background = valueOf(veilRules[0], 'background-color');
104
+ expect(background, `${file}: background-color`).toBeDefined();
105
+ expect(ALLOWED_BACKGROUND.some((allowed) => allowed.test(background)), `${file}: background-color "${background}" is not an opaque token reference`).toBe(true);
106
+ });
107
+ it(`${selector} carries the veil's alpha as opacity 0.32`, () => {
108
+ const opacity = valueOf(veilRules[0], 'opacity');
109
+ expect(opacity, `${file}: opacity`).toBeDefined();
110
+ expect(ALLOWED_OPACITY.some((allowed) => allowed.test(opacity)), `${file}: opacity "${opacity}" is not the 0.32 the component owns`).toBe(true);
111
+ });
112
+ it(`${selector} declares no local overlay token that would change the veil`, () => {
113
+ for (const rule of rules) {
114
+ for (const { property, value } of rule.declarations) {
115
+ if (/^--sp-comp-[a-z-]+-overlay-opacity$/.test(property)) {
116
+ expect(value, `${file}: ${property}`).toBe('0.32');
117
+ }
118
+ if (/^--sp-comp-[a-z-]+-overlay-color$/.test(property)) {
119
+ expect(value, `${file}: ${property}`).toBe('var(--sp-sys-color-scrim)');
120
+ }
121
+ }
122
+ }
123
+ });
124
+ it(`${selector} variants only fade the veil out, never repaint it`, () => {
125
+ for (const rule of variantRules) {
126
+ expect(valueOf(rule, 'background-color'), `${file}: "${rule.selectorList}" repaints the veil`).toBeUndefined();
127
+ const opacity = valueOf(rule, 'opacity');
128
+ if (opacity !== undefined) {
129
+ expect(opacity, `${file}: "${rule.selectorList}" opacity`).toBe('0');
130
+ }
131
+ }
132
+ });
133
+ }
134
+ });
@@ -16,6 +16,10 @@
16
16
  closeOnOutsideClick = true,
17
17
  onOpenChange = undefined,
18
18
  onClose = undefined,
19
+ itemAttrs = undefined,
20
+ disabledItemAttrs = undefined,
21
+ onItemSelect = undefined,
22
+ onDisabledItemSelect = undefined,
19
23
  }: {
20
24
  value?: string
21
25
  shouldFilter?: boolean
@@ -25,6 +29,14 @@
25
29
  closeOnOutsideClick?: boolean
26
30
  onOpenChange?: (open: boolean) => void
27
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
28
40
  } = $props()
29
41
  </script>
30
42
 
@@ -42,14 +54,24 @@
42
54
  <CommandList>
43
55
  <CommandEmpty>No results found.</CommandEmpty>
44
56
  <CommandGroup heading="Actions">
45
- <CommandItem value="copy" keywords={['duplicate']}>Copy</CommandItem>
57
+ <CommandItem
58
+ value="copy"
59
+ keywords={['duplicate']}
60
+ onSelect={onItemSelect}
61
+ {...itemAttrs ?? {}}>Copy</CommandItem
62
+ >
46
63
  <CommandItem value="paste">Paste</CommandItem>
47
64
  <CommandItem value="cut">Cut</CommandItem>
48
65
  </CommandGroup>
49
66
  <CommandSeparator />
50
67
  <CommandGroup heading="Navigation">
51
68
  <CommandItem value="home">Home</CommandItem>
52
- <CommandItem value="settings" disabled>Settings</CommandItem>
69
+ <CommandItem
70
+ value="settings"
71
+ disabled
72
+ onSelect={onDisabledItemSelect}
73
+ {...disabledItemAttrs ?? {}}>Settings</CommandItem
74
+ >
53
75
  </CommandGroup>
54
76
  </CommandList>
55
77
  </CommandRoot>
@@ -0,0 +1,284 @@
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
+ });
@@ -10,6 +10,12 @@
10
10
  onSelect,
11
11
  class: className,
12
12
  children,
13
+ // Pulled out of `rest` so the built-in behaviour can COMPOSE with them
14
+ // instead of being clobbered by the spread.
15
+ onclick,
16
+ onkeydown,
17
+ onpointermove,
18
+ ...rest
13
19
  }: CommandItemProps = $props()
14
20
  const ctx = getCommandContext()
15
21
  const id = useId('sp-command-item')
@@ -23,37 +29,68 @@
23
29
  return () => ctx.unregisterItem(id)
24
30
  })
25
31
 
26
- function onclick() {
27
- if (disabled) return
32
+ /** Svelte's DOM attribute handlers narrow `currentTarget` to the host
33
+ * element; a bare `MouseEvent` is not assignable to that. */
34
+ type RowEvent<E extends Event> = E & { currentTarget: EventTarget & HTMLDivElement }
35
+
36
+ // `onSelect` only fires after a real Spectral selection.
37
+ function select() {
28
38
  ctx.select(value)
29
39
  onSelect?.(value)
30
40
  }
31
41
 
32
- function onpointermove() {
42
+ // Activation of a disabled item is INERT, which includes the consumer's
43
+ // handler: a dimmed, unselectable row must not fire an application effect.
44
+ function handleClick(event: RowEvent<MouseEvent>) {
45
+ if (disabled) return
46
+ onclick?.(event)
47
+ if (event.defaultPrevented) return
48
+ select()
49
+ }
50
+
51
+ function handleKeydown(event: RowEvent<KeyboardEvent>) {
52
+ if (disabled) {
53
+ // Activation stays inert, but Enter/Space keep their default prevented so
54
+ // a focused, dimmed row never scrolls the list.
55
+ if (event.key === 'Enter' || event.key === ' ') event.preventDefault()
56
+ return
57
+ }
58
+ onkeydown?.(event)
59
+ if (event.defaultPrevented) return
60
+ if (event.key === 'Enter' || event.key === ' ') {
61
+ event.preventDefault()
62
+ select()
63
+ }
64
+ }
65
+
66
+ // Deliberately NOT guarded on `disabled`: a consumer may observe the pointer
67
+ // over a dimmed row; only the internal hover-selection is forbidden.
68
+ function handlePointerMove(event: RowEvent<PointerEvent>) {
69
+ onpointermove?.(event)
70
+ if (event.defaultPrevented) return
33
71
  if (!disabled && ctx.selectedValue !== value) ctx.select(value)
34
72
  }
35
73
  </script>
36
74
 
37
75
  {#if isVisible}
76
+ <!-- `{...rest}` FIRST: a consumer may add attributes, never override the
77
+ option's identity (role, id, selection state, internal data-*) — those
78
+ come after and win. `data-command-item=""` keeps its shipped form. -->
38
79
  <div
80
+ {...rest}
39
81
  class="sp-command-item {className ?? ''}"
40
82
  {id}
41
83
  role="option"
42
84
  aria-selected={isSelected}
43
85
  aria-disabled={disabled || undefined}
44
- data-command-item
86
+ data-command-item=""
45
87
  data-value={value}
46
88
  data-selected={isSelected}
47
89
  data-disabled={disabled || undefined}
48
90
  tabindex={-1}
49
- {onclick}
50
- onkeydown={(e) => {
51
- if (e.key === 'Enter' || e.key === ' ') {
52
- e.preventDefault()
53
- onclick()
54
- }
55
- }}
56
- {onpointermove}
91
+ onclick={handleClick}
92
+ onkeydown={handleKeydown}
93
+ onpointermove={handlePointerMove}
57
94
  >
58
95
  {#if children}{@render children()}{/if}
59
96
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"command-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/command/components/command-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAuDnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"command-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/command/components/command-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAyFnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
2
3
  export interface CommandProps {
3
4
  /** Current search query (bindable). */
4
5
  value?: string;
@@ -53,14 +54,38 @@ export interface CommandGroupProps {
53
54
  class?: string;
54
55
  children?: Snippet;
55
56
  }
56
- export interface CommandItemProps {
57
+ /**
58
+ * Extends the native `div` attributes (spectral-ui#205): a consumer's `title`,
59
+ * `aria-*` (including `aria-label`, which replaces the name derived from the
60
+ * content — standard ARIA precedence; `value`/`keywords` stay the filter's
61
+ * authority), `data-testid`, `style` and native event handlers reach the real
62
+ * `role="option"` node.
63
+ *
64
+ * The option's identity stays with the component: `role`, `id`, `tabindex`,
65
+ * `aria-selected` and `aria-disabled` are rejected by the type, and the
66
+ * internal `data-*` (`data-command-item`, `data-value`, `data-selected`,
67
+ * `data-disabled`) cannot be overridden — the type cannot exclude `data-*`
68
+ * keys, the markup order does.
69
+ *
70
+ * Consumer handlers COMPOSE with the built-in ones: yours runs first, and
71
+ * calling `preventDefault()` cancels the Spectral selection. Activation of a
72
+ * disabled item is inert (`onclick` / `onkeydown` do not run); `onpointermove`
73
+ * stays observable on a disabled item, which is never selected.
74
+ */
75
+ export interface CommandItemProps extends Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'children' | 'role' | 'id' | 'tabindex' | 'aria-selected' | 'aria-disabled'> {
57
76
  /** Unique value for this item. */
58
77
  value: string;
59
78
  /** Extra keywords used for search matching (not displayed). */
60
79
  keywords?: string[];
61
80
  /** Whether the item is disabled. */
62
81
  disabled?: boolean;
63
- /** Called when the item is selected (Enter or click). */
82
+ /**
83
+ * Called after a real Spectral selection (click, Enter/Space on the option,
84
+ * or Enter in the input — which reaches the option as a synthetic `click`).
85
+ * Hover-selection never fires it. `onSelect` is the Spectral callback; the
86
+ * native lowercase `onselect` DOM handler, if provided, stays an ordinary DOM
87
+ * event — Command's selection mechanisms never invoke it.
88
+ */
64
89
  onSelect?: (value: string) => void;
65
90
  class?: string;
66
91
  children?: Snippet;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/command/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAErC,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gGAAgG;IAChG,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAA;IACvE,0EAA0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,mDAAmD;IACnD,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAChD,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IACjD,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/command/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAErD,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gGAAgG;IAChG,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAA;IACvE,0EAA0E;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;OAEG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,mDAAmD;IACnD,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAChD,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IACjD,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,gBAAiB,SAAQ,IAAI,CAC5C,cAAc,CAAC,cAAc,CAAC,EAC9B,OAAO,GAAG,UAAU,GAAG,MAAM,GAAG,IAAI,GAAG,UAAU,GAAG,eAAe,GAAG,eAAe,CACtF;IACC,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf"}
@@ -1,7 +1,10 @@
1
1
  export { default as CheckboxItem } from '../dropdown-menu/components/dropdown-menu-checkbox-item.svelte';
2
2
  export { default as Item } from '../dropdown-menu/components/dropdown-menu-item.svelte';
3
3
  export { default as Label } from '../dropdown-menu/components/dropdown-menu-label.svelte';
4
+ export { default as RadioGroup } from '../dropdown-menu/components/dropdown-menu-radio-group.svelte';
5
+ export { default as RadioItem } from '../dropdown-menu/components/dropdown-menu-radio-item.svelte';
4
6
  export { default as Separator } from '../dropdown-menu/components/dropdown-menu-separator.svelte';
7
+ export type { DropdownMenuRadioGroupProps as ContextMenuRadioGroupProps, DropdownMenuRadioItemProps as ContextMenuRadioItemProps, } from '../dropdown-menu/types.js';
5
8
  export { default as Content } from './components/context-menu-content.svelte';
6
9
  export { default as Root } from './components/context-menu-root.svelte';
7
10
  export { default as Trigger } from './components/context-menu-trigger.svelte';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/context-menu/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gEAAgE,CAAA;AACxG,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uDAAuD,CAAA;AACvF,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,wDAAwD,CAAA;AACzF,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,4DAA4D,CAAA;AACjG,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,0CAA0C,CAAA;AAC7E,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uCAAuC,CAAA;AACvE,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,0CAA0C,CAAA;AAC7E,YAAY,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/context-menu/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gEAAgE,CAAA;AACxG,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uDAAuD,CAAA;AACvF,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,wDAAwD,CAAA;AACzF,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,8DAA8D,CAAA;AACpG,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,6DAA6D,CAAA;AAClG,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,4DAA4D,CAAA;AACjG,YAAY,EACV,2BAA2B,IAAI,0BAA0B,EACzD,0BAA0B,IAAI,yBAAyB,GACxD,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,0CAA0C,CAAA;AAC7E,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uCAAuC,CAAA;AACvE,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,0CAA0C,CAAA;AAC7E,YAAY,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA"}
@@ -2,6 +2,8 @@
2
2
  export { default as CheckboxItem } from '../dropdown-menu/components/dropdown-menu-checkbox-item.svelte';
3
3
  export { default as Item } from '../dropdown-menu/components/dropdown-menu-item.svelte';
4
4
  export { default as Label } from '../dropdown-menu/components/dropdown-menu-label.svelte';
5
+ export { default as RadioGroup } from '../dropdown-menu/components/dropdown-menu-radio-group.svelte';
6
+ export { default as RadioItem } from '../dropdown-menu/components/dropdown-menu-radio-item.svelte';
5
7
  export { default as Separator } from '../dropdown-menu/components/dropdown-menu-separator.svelte';
6
8
  export { default as Content } from './components/context-menu-content.svelte';
7
9
  export { default as Root } from './components/context-menu-root.svelte';