@spectral_labs/ui 1.5.1 → 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.
- package/dist/components/context-menu/components/context-menu-content.svelte +8 -7
- package/dist/components/context-menu/components/context-menu-content.svelte.d.ts.map +1 -1
- package/dist/components/context-menu/components/context-menu-root.svelte +9 -7
- package/dist/components/context-menu/components/context-menu-root.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +22 -1
- package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +438 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte +10 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte +8 -7
- package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte +8 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte +9 -7
- package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte +94 -4
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte +32 -7
- package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte +180 -6
- package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts +30 -2
- package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/types.d.ts +2 -0
- package/dist/components/dropdown-menu/types.d.ts.map +1 -1
- package/dist/components/menubar/__tests__/menubar.test.svelte.js +95 -0
- package/dist/components/menubar/components/menubar-content.svelte +8 -7
- package/dist/components/menubar/components/menubar-content.svelte.d.ts.map +1 -1
- package/dist/components/menubar/components/menubar-menu.svelte +8 -6
- package/dist/components/menubar/components/menubar-menu.svelte.d.ts.map +1 -1
- package/dist/internal/escape-layer/__tests__/escape-layer.test.js +29 -0
- package/dist/internal/escape-layer/escape-layer.svelte.d.ts +5 -1
- package/dist/internal/escape-layer/escape-layer.svelte.d.ts.map +1 -1
- package/dist/internal/escape-layer/escape-layer.svelte.js +20 -13
- package/dist/internal/menu-context/index.d.ts +2 -2
- package/dist/internal/menu-context/index.d.ts.map +1 -1
- package/dist/internal/menu-context/index.js +1 -1
- package/dist/internal/menu-context/menu-context.svelte.d.ts +64 -2
- package/dist/internal/menu-context/menu-context.svelte.d.ts.map +1 -1
- package/dist/internal/menu-context/menu-context.svelte.js +59 -0
- package/dist/internal/roving-focus/roving-focus.svelte.d.ts.map +1 -1
- package/dist/internal/roving-focus/roving-focus.svelte.js +2 -1
- package/dist/internal/roving-focus/types.d.ts +6 -0
- package/dist/internal/roving-focus/types.d.ts.map +1 -1
- 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
|
|
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
|
-
|
|
105
|
-
|
|
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;
|
|
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
|
-
|
|
24
|
-
|
|
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;
|
|
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
|
-
/**
|
|
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;
|
|
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
|
-
|
|
26
|
+
const behavior = resolveBehavior(entry);
|
|
27
|
+
if (behavior === 'close') {
|
|
18
28
|
entry.state.props.onEscapeKeydown?.(event);
|
|
19
29
|
return;
|
|
20
30
|
}
|
|
21
|
-
if (
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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":"
|
|
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;
|
|
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;
|
|
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"}
|