@spectral_labs/ui 1.5.0 → 1.5.2

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 (44) hide show
  1. package/dist/components/context-menu/components/context-menu-content.svelte +8 -7
  2. package/dist/components/context-menu/components/context-menu-content.svelte.d.ts.map +1 -1
  3. package/dist/components/context-menu/components/context-menu-root.svelte +9 -7
  4. package/dist/components/context-menu/components/context-menu-root.svelte.d.ts.map +1 -1
  5. package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +22 -1
  6. package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +438 -1
  7. package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte +11 -1
  8. package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte.d.ts.map +1 -1
  9. package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte +8 -7
  10. package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte.d.ts.map +1 -1
  11. package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte +9 -1
  12. package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte.d.ts.map +1 -1
  13. package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte +9 -7
  14. package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte.d.ts.map +1 -1
  15. package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte +94 -4
  16. package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.d.ts.map +1 -1
  17. package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte +33 -8
  18. package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.d.ts.map +1 -1
  19. package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte +180 -6
  20. package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte.d.ts.map +1 -1
  21. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts +30 -2
  22. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts.map +1 -1
  23. package/dist/components/dropdown-menu/types.d.ts +2 -0
  24. package/dist/components/dropdown-menu/types.d.ts.map +1 -1
  25. package/dist/components/menubar/__tests__/menubar.test.svelte.js +95 -0
  26. package/dist/components/menubar/components/menubar-content.svelte +8 -7
  27. package/dist/components/menubar/components/menubar-content.svelte.d.ts.map +1 -1
  28. package/dist/components/menubar/components/menubar-menu.svelte +8 -6
  29. package/dist/components/menubar/components/menubar-menu.svelte.d.ts.map +1 -1
  30. package/dist/internal/escape-layer/__tests__/escape-layer.test.js +29 -0
  31. package/dist/internal/escape-layer/escape-layer.svelte.d.ts +5 -1
  32. package/dist/internal/escape-layer/escape-layer.svelte.d.ts.map +1 -1
  33. package/dist/internal/escape-layer/escape-layer.svelte.js +20 -13
  34. package/dist/internal/menu-context/index.d.ts +2 -2
  35. package/dist/internal/menu-context/index.d.ts.map +1 -1
  36. package/dist/internal/menu-context/index.js +1 -1
  37. package/dist/internal/menu-context/menu-context.svelte.d.ts +64 -2
  38. package/dist/internal/menu-context/menu-context.svelte.d.ts.map +1 -1
  39. package/dist/internal/menu-context/menu-context.svelte.js +59 -0
  40. package/dist/internal/roving-focus/roving-focus.svelte.d.ts.map +1 -1
  41. package/dist/internal/roving-focus/roving-focus.svelte.js +2 -1
  42. package/dist/internal/roving-focus/types.d.ts +6 -0
  43. package/dist/internal/roving-focus/types.d.ts.map +1 -1
  44. package/package.json +1 -1
@@ -229,5 +229,100 @@ describe('menubar', () => {
229
229
  expect(triggers[0].getAttribute('data-state')).toBe('open');
230
230
  expect(triggers[1].getAttribute('data-state')).toBe('closed');
231
231
  });
232
+ it('arrowRight on the sub-trigger moves focus to the first sub item', () => {
233
+ setup({ withSub: true });
234
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
235
+ triggers[0].click();
236
+ flushSync();
237
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
238
+ subTrigger.focus();
239
+ pressKey('ArrowRight');
240
+ expect(document.activeElement?.textContent?.trim()).toBe('Email');
241
+ });
242
+ it('arrowLeft inside the sub closes it, restores focus to the sub-trigger and keeps the menu', () => {
243
+ setup({ withSub: true });
244
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
245
+ triggers[0].click();
246
+ flushSync();
247
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
248
+ subTrigger.focus();
249
+ pressKey('ArrowRight');
250
+ expect(document.activeElement?.textContent?.trim()).toBe('Email');
251
+ pressKey('ArrowLeft');
252
+ // Sub closed, focus back on the trigger row — no dead end on body.
253
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
254
+ expect(document.activeElement).toBe(subTrigger);
255
+ // The menubar did NOT switch to the previous menu.
256
+ expect(triggers[0].getAttribute('data-state')).toBe('open');
257
+ expect(triggers[1].getAttribute('data-state')).toBe('closed');
258
+ expect(triggers[2].getAttribute('data-state')).toBe('closed');
259
+ });
260
+ it('hovering a sibling item closes the open sub', () => {
261
+ setup({ withSub: true });
262
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
263
+ triggers[0].click();
264
+ flushSync();
265
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
266
+ subTrigger.click();
267
+ flushSync();
268
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
269
+ const newItem = Array.from(target.querySelectorAll('.sp-menubar-content .sp-dropdown-menu-item')).find((el) => el.textContent?.trim() === 'New');
270
+ newItem.dispatchEvent(new MouseEvent('pointerenter', { clientX: 0, clientY: 0 }));
271
+ flushSync();
272
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
273
+ // The menubar menu itself stayed open.
274
+ expect(triggers[0].getAttribute('data-state')).toBe('open');
275
+ });
276
+ it('escape with focus in the menu and a hover-opened sub closes the whole menu in ONE keypress and refocuses the menubar trigger', () => {
277
+ setup({ withSub: true });
278
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
279
+ triggers[0].click();
280
+ flushSync();
281
+ // Autofocus lands on the first parent row.
282
+ expect(document.activeElement?.textContent?.trim()).toBe('New');
283
+ // Pointer path: the sub opens without moving focus.
284
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
285
+ subTrigger.click();
286
+ flushSync();
287
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
288
+ expect(document.activeElement?.textContent?.trim()).toBe('New');
289
+ // ONE Escape: the sub layer defers (focus is NOT inside the sub) and the
290
+ // menubar layer closes the whole menu — focus returns to the MENUBAR
291
+ // trigger, never warped onto the sub trigger.
292
+ pressKey('Escape');
293
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
294
+ expect(target.querySelector('[role="menu"]')).toBeNull();
295
+ expect(document.activeElement).toBe(triggers[0]);
296
+ });
297
+ it('escape with focus INSIDE the sub closes only the sub (menu stays open)', () => {
298
+ setup({ withSub: true });
299
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
300
+ triggers[0].click();
301
+ flushSync();
302
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
303
+ subTrigger.focus();
304
+ pressKey('ArrowRight');
305
+ expect(document.activeElement?.textContent?.trim()).toBe('Email');
306
+ pressKey('Escape');
307
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
308
+ expect(document.activeElement).toBe(subTrigger);
309
+ // The menubar menu itself stayed open.
310
+ expect(triggers[0].getAttribute('data-state')).toBe('open');
311
+ });
312
+ it('parent roving skips sub items while the sub is open', () => {
313
+ setup({ withSub: true });
314
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
315
+ triggers[0].click();
316
+ flushSync();
317
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
318
+ // Pointer open keeps focus on the trigger row.
319
+ subTrigger.click();
320
+ flushSync();
321
+ subTrigger.focus();
322
+ // Share is the last row: ArrowDown loops to the first PARENT item,
323
+ // not into the open sub.
324
+ pressKey('ArrowDown');
325
+ expect(document.activeElement?.textContent?.trim()).toBe('New');
326
+ });
232
327
  });
233
328
  });
@@ -4,6 +4,7 @@
4
4
  import { EscapeLayerState } from '../../../internal/escape-layer/index.js'
5
5
  import { FloatingLayerState } from '../../../internal/floating-layer/index.js'
6
6
  import { isBrowser, kbd } from '../../../internal/helpers/index.js'
7
+ import { getOwnLevelMenuItems, isOwnLevelMenuItem } from '../../../internal/menu-context/index.js'
7
8
  import { Portal } from '../../../internal/portal/index.js'
8
9
  import { PresenceLayerState } from '../../../internal/presence-layer/index.js'
9
10
  import { RovingFocusGroup } from '../../../internal/roving-focus/index.js'
@@ -28,6 +29,9 @@
28
29
  orientation: 'vertical',
29
30
  loop: true,
30
31
  candidateSelector: '[data-roving-focus-item]',
32
+ // Sub content renders inline inside this content: exclude its rows from
33
+ // the parent collection (the sub content has its own roving group).
34
+ candidateFilter: (el) => !contentEl || isOwnLevelMenuItem(contentEl, el),
31
35
  })
32
36
 
33
37
  const presence = new PresenceLayerState({
@@ -79,10 +83,8 @@
79
83
  floating.setElements(menu.triggerEl, contentEl)
80
84
  floating.startAutoUpdate()
81
85
 
82
- // Focus first interactive item
83
- const firstItem = contentEl.querySelector<HTMLElement>(
84
- '[data-roving-focus-item]:not([data-disabled])',
85
- )
86
+ // Focus first interactive item (own level only — never a sub row)
87
+ const firstItem = getOwnLevelMenuItems(contentEl)[0]
86
88
  firstItem?.focus()
87
89
  }
88
90
 
@@ -101,9 +103,8 @@
101
103
  if (!isBrowser) return
102
104
  if (!contentEl) return
103
105
 
104
- const items = Array.from(
105
- contentEl.querySelectorAll<HTMLElement>('[data-roving-focus-item]:not([data-disabled])'),
106
- )
106
+ // Typeahead collection scoped to this level (sub rows excluded).
107
+ const items = getOwnLevelMenuItems(contentEl)
107
108
 
108
109
  switch (event.key) {
109
110
  case kbd.ARROW_DOWN: {
@@ -1 +1 @@
1
- {"version":3,"file":"menubar-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/menubar/components/menubar-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AA4MtD,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"menubar-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/menubar/components/menubar-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AA8MtD,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { MenubarMenuProps } from '../types.js'
3
- import { setMenuContext } from '../../../internal/menu-context/index.js'
3
+ import { createMenuContext, setMenuContext } from '../../../internal/menu-context/index.js'
4
4
  import {
5
5
  createMenubarMenuIds,
6
6
  getMenubarContext,
@@ -19,11 +19,13 @@
19
19
 
20
20
  // Set the shared MenuContext so dropdown-menu items (Item, CheckboxItem, etc.)
21
21
  // can call close() correctly
22
- setMenuContext({
23
- close() {
24
- root.closeMenu()
25
- },
26
- })
22
+ setMenuContext(
23
+ createMenuContext({
24
+ close() {
25
+ root.closeMenu()
26
+ },
27
+ }),
28
+ )
27
29
 
28
30
  setMenubarMenuContext({
29
31
  value,
@@ -1 +1 @@
1
- {"version":3,"file":"menubar-menu.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/menubar/components/menubar-menu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAsDnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"menubar-menu.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/menubar/components/menubar-menu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAwDnD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -81,4 +81,33 @@ describe('escapeLayer', () => {
81
81
  // Should not throw after cleanup
82
82
  document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
83
83
  });
84
+ it('resolves behavior at keydown time (getter can flip between keypresses)', () => {
85
+ // A layer may pass `behavior` as a getter and decide per keypress — the
86
+ // submenu layer closes itself only when focus lives inside its content,
87
+ // and defers to the parent menu layer otherwise.
88
+ let topBehavior = 'close';
89
+ const onBottom = vi.fn();
90
+ const onTop = vi.fn();
91
+ const bottom = new EscapeLayerState({ enabled: true, onEscapeKeydown: onBottom });
92
+ const top = new EscapeLayerState({
93
+ enabled: true,
94
+ get behavior() {
95
+ return topBehavior;
96
+ },
97
+ onEscapeKeydown: onTop,
98
+ });
99
+ bottom.mount();
100
+ top.mount();
101
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
102
+ expect(onTop).toHaveBeenCalledOnce();
103
+ expect(onBottom).not.toHaveBeenCalled();
104
+ // Same mounted layer, new answer from the getter: the keypress falls
105
+ // through to the bottom layer in the SAME stack state.
106
+ topBehavior = 'defer-otherwise-close';
107
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
108
+ expect(onTop).toHaveBeenCalledOnce();
109
+ expect(onBottom).toHaveBeenCalledOnce();
110
+ top.unmount();
111
+ bottom.unmount();
112
+ });
84
113
  });
@@ -10,7 +10,11 @@ export declare class EscapeLayerState {
10
10
  constructor(props: EscapeLayerProps);
11
11
  mount(): void;
12
12
  unmount(): void;
13
- /** Update behavior dynamically. */
13
+ /**
14
+ * Update behavior dynamically. Writes through to `props.behavior` (behavior
15
+ * is resolved live at keydown time); only meaningful when `behavior` was
16
+ * provided as a plain value, not a getter.
17
+ */
14
18
  updateBehavior(behavior: EscapeKeydownBehavior): void;
15
19
  }
16
20
  /** Reset the layer stack (for testing only). */
@@ -1 +1 @@
1
- {"version":3,"file":"escape-layer.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/escape-layer/escape-layer.svelte.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAkCzE,qBAAa,gBAAgB;IAC3B,KAAK,EAAE,gBAAgB,CAAA;gBAEX,KAAK,EAAE,gBAAgB;IAInC,KAAK,IAAI,IAAI;IAiBb,OAAO,IAAI,IAAI;IAcf,mCAAmC;IACnC,cAAc,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI;CAMtD;AAED,gDAAgD;AAChD,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C"}
1
+ {"version":3,"file":"escape-layer.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/escape-layer/escape-layer.svelte.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AA4CzE,qBAAa,gBAAgB;IAC3B,KAAK,EAAE,gBAAgB,CAAA;gBAEX,KAAK,EAAE,gBAAgB;IAInC,KAAK,IAAI,IAAI;IAYb,OAAO,IAAI,IAAI;IAcf;;;;OAIG;IACH,cAAc,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI;CAGtD;AAED,gDAAgD;AAChD,wBAAgB,qBAAqB,IAAI,IAAI,CAK5C"}
@@ -6,6 +6,15 @@
6
6
  */
7
7
  import { isBrowser, kbd } from '../helpers/index.js';
8
8
  const layerStack = [];
9
+ /**
10
+ * Behavior is resolved at KEYDOWN time, not mount time, so a layer can pass
11
+ * `behavior` as a getter and decide per keypress — e.g. the submenu layer
12
+ * closes only itself when focus lives inside its content, and defers to the
13
+ * parent menu layer otherwise (APG: Escape closes the menu containing focus).
14
+ */
15
+ function resolveBehavior(entry) {
16
+ return entry.state.props.behavior ?? 'close';
17
+ }
9
18
  function handleGlobalKeydown(event) {
10
19
  if (event.key !== kbd.ESCAPE)
11
20
  return;
@@ -14,18 +23,19 @@ function handleGlobalKeydown(event) {
14
23
  // Find the topmost layer that should handle the escape
15
24
  for (let i = layerStack.length - 1; i >= 0; i--) {
16
25
  const entry = layerStack[i];
17
- if (entry.behavior === 'close') {
26
+ const behavior = resolveBehavior(entry);
27
+ if (behavior === 'close') {
18
28
  entry.state.props.onEscapeKeydown?.(event);
19
29
  return;
20
30
  }
21
- if (entry.behavior === 'ignore') {
31
+ if (behavior === 'ignore') {
22
32
  return;
23
33
  }
24
34
  // 'defer-otherwise-close' — continue to next layer
25
35
  }
26
36
  // If all layers deferred, the bottom layer handles it
27
37
  const bottom = layerStack[0];
28
- if (bottom && bottom.behavior === 'defer-otherwise-close') {
38
+ if (bottom && resolveBehavior(bottom) === 'defer-otherwise-close') {
29
39
  bottom.state.props.onEscapeKeydown?.(event);
30
40
  }
31
41
  }
@@ -39,15 +49,11 @@ export class EscapeLayerState {
39
49
  return;
40
50
  if (!this.props.enabled)
41
51
  return;
42
- const entry = {
43
- state: this,
44
- behavior: this.props.behavior ?? 'close',
45
- };
46
52
  // Add global listener if this is the first layer
47
53
  if (layerStack.length === 0) {
48
54
  document.addEventListener('keydown', handleGlobalKeydown, true);
49
55
  }
50
- layerStack.push(entry);
56
+ layerStack.push({ state: this });
51
57
  }
52
58
  unmount() {
53
59
  if (!isBrowser)
@@ -61,12 +67,13 @@ export class EscapeLayerState {
61
67
  document.removeEventListener('keydown', handleGlobalKeydown, true);
62
68
  }
63
69
  }
64
- /** Update behavior dynamically. */
70
+ /**
71
+ * Update behavior dynamically. Writes through to `props.behavior` (behavior
72
+ * is resolved live at keydown time); only meaningful when `behavior` was
73
+ * provided as a plain value, not a getter.
74
+ */
65
75
  updateBehavior(behavior) {
66
- const entry = layerStack.find((e) => e.state === this);
67
- if (entry) {
68
- entry.behavior = behavior;
69
- }
76
+ this.props.behavior = behavior;
70
77
  }
71
78
  }
72
79
  /** Reset the layer stack (for testing only). */
@@ -1,3 +1,3 @@
1
- export { getMenuContext, setMenuContext } from './menu-context.svelte.js';
2
- export type { MenuContext } from './menu-context.svelte.js';
1
+ export { createMenuContext, getMenuContext, getOwnLevelMenuItems, isOwnLevelMenuItem, MENU_SUB_CONTENT_SELECTOR, setMenuContext, } from './menu-context.svelte.js';
2
+ export type { MenuContext, MenuSubHandle } from './menu-context.svelte.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/menu-context/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AACzE,YAAY,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/menu-context/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,kBAAkB,EAClB,yBAAyB,EACzB,cAAc,GACf,MAAM,0BAA0B,CAAA;AACjC,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA"}
@@ -1 +1 @@
1
- export { getMenuContext, setMenuContext } from './menu-context.svelte.js';
1
+ export { createMenuContext, getMenuContext, getOwnLevelMenuItems, isOwnLevelMenuItem, MENU_SUB_CONTENT_SELECTOR, setMenuContext, } from './menu-context.svelte.js';
@@ -1,10 +1,72 @@
1
+ import type { Point } from '../grace-area/types.js';
1
2
  /**
2
- * Shared menu context used by both DropdownMenu and ContextMenu.
3
- * Provides the minimal interface needed by shared sub-components (Item, CheckboxItem, etc.).
3
+ * Handle a submenu registers on its parent menu level so that sibling
4
+ * rows can coordinate pointer behavior (macOS/Radix model: hovering
5
+ * another item of the parent menu closes any open submenu, unless the
6
+ * pointer is traveling through the submenu's grace area).
7
+ */
8
+ export interface MenuSubHandle {
9
+ /** Whether `target` belongs to this submenu (its trigger or its content). */
10
+ ownsTarget: (target: EventTarget | null) => boolean;
11
+ /** Whether the pointer is currently traveling through this submenu's grace area. */
12
+ isPointerInGrace: (point: Point) => boolean;
13
+ /**
14
+ * Closes the submenu (pointer path). The submenu still restores focus to
15
+ * its trigger when keyboard focus was inside its content (mixed modality),
16
+ * so focus is never orphaned on `document.body`.
17
+ */
18
+ closeSub: () => void;
19
+ }
20
+ /**
21
+ * Shared menu context used by DropdownMenu, ContextMenu and Menubar menus.
22
+ * Provides the minimal interface needed by shared sub-components
23
+ * (Item, CheckboxItem, Sub, etc.).
4
24
  */
5
25
  export interface MenuContext {
6
26
  close: () => void;
27
+ /** Registers a submenu living at this menu level. Returns an unregister fn. */
28
+ registerSub: (sub: MenuSubHandle) => () => void;
29
+ /**
30
+ * Pointer coordination — called on `pointerenter` of any item row at this
31
+ * level. Closes every registered submenu that neither owns the entered
32
+ * element nor has the pointer inside its grace area.
33
+ */
34
+ onItemEnter: (event: PointerEvent) => void;
35
+ /**
36
+ * Single-open-sub invariant (Radix semantics: one open sub per menu level).
37
+ * Called by a submenu right before it opens: force-closes every OTHER
38
+ * registered submenu at this level, including one spared by a grace-area
39
+ * check (its grace tracking and safety timer are cancelled by its
40
+ * `closeSub`). Covers keyboard opens, which never go through `onItemEnter`.
41
+ */
42
+ closeOtherSubs: (keep: MenuSubHandle) => void;
7
43
  }
44
+ /**
45
+ * Builds a `MenuContext` with a per-menu submenu registry.
46
+ * SSR-safe: no DOM access happens here — only event handlers touch the DOM.
47
+ */
48
+ export declare function createMenuContext(options: {
49
+ close: () => void;
50
+ }): MenuContext;
8
51
  export declare function setMenuContext(ctx: MenuContext): void;
9
52
  export declare function getMenuContext(): MenuContext;
53
+ /** Class hook of the inline submenu content (shared by every menu family). */
54
+ export declare const MENU_SUB_CONTENT_SELECTOR = ".sp-dropdown-menu-sub-content";
55
+ /**
56
+ * Whether `candidate` belongs to `container`'s own menu level.
57
+ *
58
+ * Submenu content renders INLINE inside the parent menu content (no portal),
59
+ * so a naive `querySelectorAll('[data-roving-focus-item]')` on the parent
60
+ * leaks the submenu's items into the parent's roving/typeahead collections.
61
+ * A candidate is "own level" when its closest sub-content ancestor is either
62
+ * absent, the container itself (items directly inside a sub-content), or
63
+ * outside the container.
64
+ */
65
+ export declare function isOwnLevelMenuItem(container: HTMLElement, candidate: HTMLElement): boolean;
66
+ /**
67
+ * Returns the enabled roving candidates that belong to `container`'s own
68
+ * menu level (excluding rows nested in an open submenu). Used for roving
69
+ * focus, typeahead and open-autofocus in every menu content.
70
+ */
71
+ export declare function getOwnLevelMenuItems(container: HTMLElement): HTMLElement[];
10
72
  //# sourceMappingURL=menu-context.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"menu-context.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/menu-context/menu-context.svelte.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAErD;AAED,wBAAgB,cAAc,IAAI,WAAW,CAE5C"}
1
+ {"version":3,"file":"menu-context.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/menu-context/menu-context.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAMnD;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,UAAU,EAAE,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,KAAK,OAAO,CAAA;IACnD,oFAAoF;IACpF,gBAAgB,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAA;IAC3C;;;;OAIG;IACH,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,+EAA+E;IAC/E,WAAW,EAAE,CAAC,GAAG,EAAE,aAAa,KAAK,MAAM,IAAI,CAAA;IAC/C;;;;OAIG;IACH,WAAW,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAA;IAC1C;;;;;;OAMG;IACH,cAAc,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAA;CAC9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,GAAG,WAAW,CA0B7E;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAErD;AAED,wBAAgB,cAAc,IAAI,WAAW,CAE5C;AAED,8EAA8E;AAC9E,eAAO,MAAM,yBAAyB,kCAAkC,CAAA;AAExE;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,WAAW,GAAG,OAAO,CAG1F;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,WAAW,GAAG,WAAW,EAAE,CAI1E"}
@@ -1,8 +1,67 @@
1
1
  import { getContext, setContext } from 'svelte';
2
+ import { SvelteSet } from 'svelte/reactivity';
2
3
  const MENU_KEY = Symbol('sp-menu');
4
+ /**
5
+ * Builds a `MenuContext` with a per-menu submenu registry.
6
+ * SSR-safe: no DOM access happens here — only event handlers touch the DOM.
7
+ */
8
+ export function createMenuContext(options) {
9
+ const subs = new SvelteSet();
10
+ return {
11
+ close: options.close,
12
+ registerSub(sub) {
13
+ subs.add(sub);
14
+ return () => {
15
+ subs.delete(sub);
16
+ };
17
+ },
18
+ onItemEnter(event) {
19
+ if (subs.size === 0)
20
+ return;
21
+ const point = { x: event.clientX, y: event.clientY };
22
+ for (const sub of subs) {
23
+ if (sub.ownsTarget(event.target))
24
+ continue;
25
+ if (sub.isPointerInGrace(point))
26
+ continue;
27
+ sub.closeSub();
28
+ }
29
+ },
30
+ closeOtherSubs(keep) {
31
+ for (const sub of subs) {
32
+ if (sub !== keep)
33
+ sub.closeSub();
34
+ }
35
+ },
36
+ };
37
+ }
3
38
  export function setMenuContext(ctx) {
4
39
  setContext(MENU_KEY, ctx);
5
40
  }
6
41
  export function getMenuContext() {
7
42
  return getContext(MENU_KEY);
8
43
  }
44
+ /** Class hook of the inline submenu content (shared by every menu family). */
45
+ export const MENU_SUB_CONTENT_SELECTOR = '.sp-dropdown-menu-sub-content';
46
+ /**
47
+ * Whether `candidate` belongs to `container`'s own menu level.
48
+ *
49
+ * Submenu content renders INLINE inside the parent menu content (no portal),
50
+ * so a naive `querySelectorAll('[data-roving-focus-item]')` on the parent
51
+ * leaks the submenu's items into the parent's roving/typeahead collections.
52
+ * A candidate is "own level" when its closest sub-content ancestor is either
53
+ * absent, the container itself (items directly inside a sub-content), or
54
+ * outside the container.
55
+ */
56
+ export function isOwnLevelMenuItem(container, candidate) {
57
+ const subAncestor = candidate.closest(MENU_SUB_CONTENT_SELECTOR);
58
+ return subAncestor === null || subAncestor === container || !container.contains(subAncestor);
59
+ }
60
+ /**
61
+ * Returns the enabled roving candidates that belong to `container`'s own
62
+ * menu level (excluding rows nested in an open submenu). Used for roving
63
+ * focus, typeahead and open-autofocus in every menu content.
64
+ */
65
+ export function getOwnLevelMenuItems(container) {
66
+ return Array.from(container.querySelectorAll('[data-roving-focus-item]:not([data-disabled])')).filter((el) => isOwnLevelMenuItem(container, el));
67
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"roving-focus.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/roving-focus/roving-focus.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAKlD;;;;;GAKG;AACH,qBAAa,gBAAgB;;gBAGf,KAAK,EAAE,gBAAgB;IAInC;;;OAGG;IACH,aAAa,CAAC,SAAS,EAAE,WAAW,GAAG,WAAW,EAAE;IAMpD;;;;;;;OAOG;IACH,aAAa,CAAC,SAAS,EAAE,WAAW,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI;IA2DjE;;;;;;OAMG;IACH,WAAW,CAAC,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;CAOpE"}
1
+ {"version":3,"file":"roving-focus.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/roving-focus/roving-focus.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAKlD;;;;;GAKG;AACH,qBAAa,gBAAgB;;gBAGf,KAAK,EAAE,gBAAgB;IAInC;;;OAGG;IACH,aAAa,CAAC,SAAS,EAAE,WAAW,GAAG,WAAW,EAAE;IAOpD;;;;;;;OAOG;IACH,aAAa,CAAC,SAAS,EAAE,WAAW,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI;IA2DjE;;;;;;OAMG;IACH,WAAW,CAAC,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;CAOpE"}
@@ -17,8 +17,9 @@ export class RovingFocusGroup {
17
17
  */
18
18
  getCandidates(container) {
19
19
  const selector = this.#props.candidateSelector ?? DEFAULT_CANDIDATE_SELECTOR;
20
+ const filter = this.#props.candidateFilter;
20
21
  const all = Array.from(container.querySelectorAll(selector));
21
- return all.filter((el) => !el.hasAttribute('data-disabled'));
22
+ return all.filter((el) => !el.hasAttribute('data-disabled') && (filter?.(el) ?? true));
22
23
  }
23
24
  /**
24
25
  * Handles keyboard navigation within the roving focus group.
@@ -8,5 +8,11 @@ export interface RovingFocusProps {
8
8
  loop?: boolean;
9
9
  /** CSS selector or data attribute for candidate elements. */
10
10
  candidateSelector?: string;
11
+ /**
12
+ * Optional predicate applied on top of `candidateSelector`.
13
+ * Return `false` to exclude a matched element from the collection
14
+ * (e.g. menu contents exclude rows nested in an open submenu).
15
+ */
16
+ candidateFilter?: (candidate: HTMLElement) => boolean;
11
17
  }
12
18
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/roving-focus/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/D,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,WAAW,EAAE,WAAW,CAAA;IACxB,sBAAsB;IACtB,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,mCAAmC;IACnC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,6DAA6D;IAC7D,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/internal/roving-focus/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAE/D,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,WAAW,EAAE,WAAW,CAAA;IACxB,sBAAsB;IACtB,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,mCAAmC;IACnC,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,6DAA6D;IAC7D,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,CAAA;CACtD"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.5.0",
4
+ "version": "1.5.2",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"