@spectral_labs/ui 1.5.1 → 1.5.3
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/context-menu/index.d.ts +3 -0
- package/dist/components/context-menu/index.d.ts.map +1 -1
- package/dist/components/context-menu/index.js +2 -0
- package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +90 -0
- package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +22 -1
- package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +306 -0
- 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-radio-group.svelte +45 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts +5 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts.map +1 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte +143 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts +5 -0
- package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts.map +1 -0
- 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 +48 -2
- package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts.map +1 -1
- package/dist/components/dropdown-menu/dropdown-menu.svelte.js +16 -0
- package/dist/components/dropdown-menu/index.d.ts +3 -1
- package/dist/components/dropdown-menu/index.d.ts.map +1 -1
- package/dist/components/dropdown-menu/index.js +2 -0
- package/dist/components/dropdown-menu/types.d.ts +37 -1
- 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/components/menubar/index.d.ts +3 -1
- package/dist/components/menubar/index.d.ts.map +1 -1
- package/dist/components/menubar/index.js +2 -0
- 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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DropdownMenuRadioItemProps } from '../types.js'
|
|
3
|
+
import { getMenuContext } from '../../../internal/menu-context/index.js'
|
|
4
|
+
import { getDropdownMenuRadioGroupContext } from '../dropdown-menu.svelte.js'
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
value,
|
|
8
|
+
disabled = false,
|
|
9
|
+
textValue,
|
|
10
|
+
closeOnSelect = true,
|
|
11
|
+
onSelect,
|
|
12
|
+
class: className,
|
|
13
|
+
children,
|
|
14
|
+
// Pulled out of `rest` so the built-in behaviour can COMPOSE with them
|
|
15
|
+
// instead of being clobbered by the spread.
|
|
16
|
+
onclick,
|
|
17
|
+
onkeydown,
|
|
18
|
+
onpointerenter,
|
|
19
|
+
...rest
|
|
20
|
+
}: DropdownMenuRadioItemProps = $props()
|
|
21
|
+
|
|
22
|
+
const group = getDropdownMenuRadioGroupContext()
|
|
23
|
+
const menuCtx = getMenuContext()
|
|
24
|
+
|
|
25
|
+
const checked = $derived(group.value === value)
|
|
26
|
+
|
|
27
|
+
/** Svelte's DOM attribute handlers narrow `currentTarget` to the host
|
|
28
|
+
* element; a bare `MouseEvent` is not assignable to that. */
|
|
29
|
+
type RowEvent<E extends Event> = E & { currentTarget: EventTarget & HTMLDivElement }
|
|
30
|
+
|
|
31
|
+
function select(event: Event) {
|
|
32
|
+
if (disabled) return
|
|
33
|
+
group.select(value)
|
|
34
|
+
onSelect?.(event)
|
|
35
|
+
// Re-selecting the current row still dismisses: picking the option you
|
|
36
|
+
// already have is a legitimate way to close the menu.
|
|
37
|
+
if (closeOnSelect) menuCtx.close()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A disabled row is INERT, which has to include the consumer's handler:
|
|
41
|
+
// running it would let a dimmed, unselectable row still fire an application
|
|
42
|
+
// effect. Activation paths bail before anything runs.
|
|
43
|
+
function handleClick(event: RowEvent<MouseEvent>) {
|
|
44
|
+
if (disabled) return
|
|
45
|
+
onclick?.(event)
|
|
46
|
+
if (event.defaultPrevented) return
|
|
47
|
+
select(event)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleKeydown(event: RowEvent<KeyboardEvent>) {
|
|
51
|
+
if (disabled) return
|
|
52
|
+
onkeydown?.(event)
|
|
53
|
+
if (event.defaultPrevented) return
|
|
54
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
select(event)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Deliberately NOT guarded on `disabled`: hovering a dimmed row must still
|
|
61
|
+
// close open sibling submenus, exactly as `Item` does.
|
|
62
|
+
function handlePointerEnter(event: RowEvent<PointerEvent>) {
|
|
63
|
+
onpointerenter?.(event)
|
|
64
|
+
if (event.defaultPrevented) return
|
|
65
|
+
// Pointer coordination: entering any row of this menu level closes open
|
|
66
|
+
// sibling submenus (unless the pointer travels through a grace area).
|
|
67
|
+
// Runs even when disabled — hovering a disabled row still closes subs.
|
|
68
|
+
menuCtx.onItemEnter(event)
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<!-- `{...rest}` FIRST: a consumer may add attributes, never override the
|
|
73
|
+
row's identity (role, checked state, roving hooks) — those come after and
|
|
74
|
+
win. -->
|
|
75
|
+
<div
|
|
76
|
+
{...rest}
|
|
77
|
+
class="sp-dropdown-menu-item radio {className ?? ''}"
|
|
78
|
+
role="menuitemradio"
|
|
79
|
+
aria-checked={checked}
|
|
80
|
+
aria-disabled={disabled || undefined}
|
|
81
|
+
tabindex={-1}
|
|
82
|
+
data-roving-focus-item
|
|
83
|
+
data-value={value}
|
|
84
|
+
data-text-value={textValue}
|
|
85
|
+
data-disabled={disabled || undefined}
|
|
86
|
+
data-state={checked ? 'checked' : 'unchecked'}
|
|
87
|
+
onpointerenter={handlePointerEnter}
|
|
88
|
+
onclick={handleClick}
|
|
89
|
+
onkeydown={handleKeydown}
|
|
90
|
+
>
|
|
91
|
+
<span class="sp-dropdown-menu-dot">
|
|
92
|
+
{#if checked}
|
|
93
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
94
|
+
<circle cx="12" cy="12" r="5"></circle>
|
|
95
|
+
</svg>
|
|
96
|
+
{/if}
|
|
97
|
+
</span>
|
|
98
|
+
{#if children}{@render children()}{/if}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<style>
|
|
102
|
+
/* Same row metrics as `.checkbox` — the two families must sit on one rhythm
|
|
103
|
+
when a menu mixes them (Preparo's Affichage menu does). */
|
|
104
|
+
.sp-dropdown-menu-item.radio {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 0.5rem;
|
|
108
|
+
padding: 0.5rem 0.75rem;
|
|
109
|
+
border-radius: var(--sp-sys-shape-corner-md, 12px);
|
|
110
|
+
font-family: var(--sp-sys-typescale-body-medium-font, inherit);
|
|
111
|
+
font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
|
|
112
|
+
color: var(--sp-sys-color-on-surface);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
user-select: none;
|
|
115
|
+
outline: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.sp-dropdown-menu-item.radio:focus-visible,
|
|
119
|
+
.sp-dropdown-menu-item.radio:hover:not([data-disabled]) {
|
|
120
|
+
background-color: color-mix(
|
|
121
|
+
in oklch,
|
|
122
|
+
var(--sp-sys-color-on-surface) calc(var(--sp-sys-state-hover-opacity, 0.08) * 100%),
|
|
123
|
+
transparent
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.sp-dropdown-menu-item.radio[data-disabled] {
|
|
128
|
+
opacity: 0.38;
|
|
129
|
+
cursor: not-allowed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.sp-dropdown-menu-dot {
|
|
133
|
+
width: 16px;
|
|
134
|
+
height: 16px;
|
|
135
|
+
flex-shrink: 0;
|
|
136
|
+
color: var(--sp-sys-color-primary);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.sp-dropdown-menu-dot svg {
|
|
140
|
+
width: 16px;
|
|
141
|
+
height: 16px;
|
|
142
|
+
}
|
|
143
|
+
</style>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DropdownMenuRadioItemProps } from '../types.js';
|
|
2
|
+
declare const DropdownMenuRadioItem: import("svelte").Component<DropdownMenuRadioItemProps, {}, "">;
|
|
3
|
+
type DropdownMenuRadioItem = ReturnType<typeof DropdownMenuRadioItem>;
|
|
4
|
+
export default DropdownMenuRadioItem;
|
|
5
|
+
//# sourceMappingURL=dropdown-menu-radio-item.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dropdown-menu-radio-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AA2F7D,QAAA,MAAM,qBAAqB,gEAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { DropdownMenuProps } 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 { createDropdownMenuIds, setDropdownMenuContext } from '../dropdown-menu.svelte.js'
|
|
5
5
|
|
|
6
6
|
let {
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
const ids = createDropdownMenuIds()
|
|
14
14
|
let triggerEl: HTMLElement | null = $state(null)
|
|
15
15
|
|
|
16
|
-
setMenuContext(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
setMenuContext(
|
|
17
|
+
createMenuContext({
|
|
18
|
+
close() {
|
|
19
|
+
open = false
|
|
20
|
+
onOpenChange?.(false)
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
22
24
|
|
|
23
25
|
setDropdownMenuContext({
|
|
24
26
|
get open() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dropdown-menu-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"dropdown-menu-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AA0DpD,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { DropdownMenuSubContentProps } from '../types.js'
|
|
3
|
+
import { EscapeLayerState } from '../../../internal/escape-layer/index.js'
|
|
3
4
|
import { isBrowser, kbd } from '../../../internal/helpers/index.js'
|
|
5
|
+
import { getOwnLevelMenuItems, isOwnLevelMenuItem } from '../../../internal/menu-context/index.js'
|
|
6
|
+
import { RovingFocusGroup } from '../../../internal/roving-focus/index.js'
|
|
7
|
+
import { TypeaheadSearch } from '../../../internal/typeahead/index.js'
|
|
4
8
|
import { getDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
|
|
5
9
|
|
|
6
10
|
const { class: className, children }: DropdownMenuSubContentProps = $props()
|
|
@@ -9,6 +13,59 @@
|
|
|
9
13
|
|
|
10
14
|
let contentEl: HTMLElement | undefined = $state()
|
|
11
15
|
|
|
16
|
+
const typeahead = new TypeaheadSearch()
|
|
17
|
+
const rovingFocus = new RovingFocusGroup({
|
|
18
|
+
orientation: 'vertical',
|
|
19
|
+
loop: true,
|
|
20
|
+
candidateSelector: '[data-roving-focus-item]',
|
|
21
|
+
// Scope to our own rows: exclude rows nested in a deeper open submenu.
|
|
22
|
+
candidateFilter: (el) => !contentEl || isOwnLevelMenuItem(contentEl, el),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// The submenu mounts its own escape layer while open, so it sits on top of
|
|
26
|
+
// the parent content's layer in the escape stack. APG: Escape closes the
|
|
27
|
+
// menu containing FOCUS — behavior is resolved per keypress:
|
|
28
|
+
// - focus inside the sub content → 'close': Escape closes ONLY the submenu
|
|
29
|
+
// and restores focus to its trigger;
|
|
30
|
+
// - focus elsewhere (hover-opened sub while keyboard focus stayed in the
|
|
31
|
+
// parent menu) → 'defer-otherwise-close': the same keypress falls through
|
|
32
|
+
// to the parent menu's layer, which closes the whole menu (this inline
|
|
33
|
+
// sub unmounts with it) — no focus teleport to the sub trigger.
|
|
34
|
+
const escapeLayer = new EscapeLayerState({
|
|
35
|
+
enabled: true,
|
|
36
|
+
get behavior() {
|
|
37
|
+
return contentEl?.contains(document.activeElement) ? 'close' : 'defer-otherwise-close'
|
|
38
|
+
},
|
|
39
|
+
onEscapeKeydown() {
|
|
40
|
+
sub.closeSub({ restoreFocus: true })
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
$effect(() => {
|
|
45
|
+
if (sub.open && isBrowser && contentEl) {
|
|
46
|
+
escapeLayer.mount()
|
|
47
|
+
}
|
|
48
|
+
return () => escapeLayer.unmount()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Report the content element to the Sub coordinator (grace area target,
|
|
52
|
+
// pointer-ownership checks).
|
|
53
|
+
$effect(() => {
|
|
54
|
+
sub.setContentEl(contentEl ?? null)
|
|
55
|
+
return () => sub.setContentEl(null)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Keyboard open (ArrowRight/Enter/Space on the trigger) moves focus to the
|
|
59
|
+
// first enabled item once the content is mounted. Pointer open never sets
|
|
60
|
+
// the request, so hover never steals focus (APG).
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (sub.open && contentEl && sub.focusContentOnOpen) {
|
|
63
|
+
const firstItem = getOwnLevelMenuItems(contentEl)[0]
|
|
64
|
+
firstItem?.focus()
|
|
65
|
+
sub.consumeFocusContentRequest()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
12
69
|
// Position the sub-content next to the trigger using fixed positioning
|
|
13
70
|
const posStyle = $derived.by(() => {
|
|
14
71
|
if (!sub.open || !sub.triggerEl || !isBrowser) return ''
|
|
@@ -21,10 +78,40 @@
|
|
|
21
78
|
})
|
|
22
79
|
|
|
23
80
|
function onKeyDown(e: KeyboardEvent) {
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
81
|
+
if (!contentEl) return
|
|
82
|
+
|
|
83
|
+
switch (e.key) {
|
|
84
|
+
case kbd.ARROW_LEFT:
|
|
85
|
+
case kbd.ESCAPE: {
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
// Fully handled here: without stopPropagation, menubar-content would
|
|
88
|
+
// treat ArrowLeft as "switch to the previous menubar menu".
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
sub.closeSub({ restoreFocus: true })
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
case kbd.ARROW_DOWN:
|
|
94
|
+
case kbd.ARROW_UP:
|
|
95
|
+
case kbd.HOME:
|
|
96
|
+
case kbd.END: {
|
|
97
|
+
// Navigate our own rows; stop the event so the parent content's
|
|
98
|
+
// roving group (which excludes our rows) never handles it.
|
|
99
|
+
e.stopPropagation()
|
|
100
|
+
rovingFocus.handleKeydown(contentEl, e)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
default: {
|
|
104
|
+
// Typeahead over our own rows only.
|
|
105
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
|
106
|
+
e.stopPropagation()
|
|
107
|
+
const items = getOwnLevelMenuItems(contentEl)
|
|
108
|
+
const labels = items.map(
|
|
109
|
+
(el) => el.getAttribute('data-text-value') ?? el.textContent?.trim() ?? '',
|
|
110
|
+
)
|
|
111
|
+
const match = typeahead.handleTypeahead(e.key, labels)
|
|
112
|
+
if (match >= 0) items[match]?.focus()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
28
115
|
}
|
|
29
116
|
}
|
|
30
117
|
</script>
|
|
@@ -33,7 +120,10 @@
|
|
|
33
120
|
<div
|
|
34
121
|
bind:this={contentEl}
|
|
35
122
|
class="sp-dropdown-menu-sub-content {className ?? ''}"
|
|
123
|
+
id={sub.contentId}
|
|
36
124
|
role="menu"
|
|
125
|
+
aria-orientation="vertical"
|
|
126
|
+
aria-labelledby={sub.triggerId}
|
|
37
127
|
tabindex="-1"
|
|
38
128
|
style={posStyle}
|
|
39
129
|
onkeydown={onKeyDown}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dropdown-menu-sub-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"dropdown-menu-sub-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAwI9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
import type { DropdownMenuSubTriggerProps } from '../types.js'
|
|
3
3
|
import { getDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
|
|
4
4
|
|
|
5
|
-
const {
|
|
5
|
+
const {
|
|
6
|
+
disabled = false,
|
|
7
|
+
textValue,
|
|
8
|
+
class: className,
|
|
9
|
+
children,
|
|
10
|
+
}: DropdownMenuSubTriggerProps = $props()
|
|
6
11
|
|
|
7
12
|
const sub = getDropdownMenuSubContext()
|
|
8
13
|
|
|
@@ -13,18 +18,34 @@
|
|
|
13
18
|
if (el) sub.setTriggerEl(el)
|
|
14
19
|
})
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
21
|
+
// Cancel a pending hover-open when the trigger unmounts mid-delay.
|
|
22
|
+
$effect(() => {
|
|
23
|
+
return () => clearHoverTimer()
|
|
24
|
+
})
|
|
20
25
|
|
|
21
|
-
function
|
|
26
|
+
function clearHoverTimer() {
|
|
22
27
|
if (hoverTimer) {
|
|
23
28
|
clearTimeout(hoverTimer)
|
|
24
29
|
hoverTimer = null
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
function onPointerEnter(e: PointerEvent) {
|
|
34
|
+
// Pointer coordination runs even when disabled: hovering a disabled sub
|
|
35
|
+
// trigger still closes sibling submenus (it is a row of the parent menu).
|
|
36
|
+
sub.onTriggerEnter(e)
|
|
37
|
+
if (disabled) return
|
|
38
|
+
if (sub.open) return
|
|
39
|
+
clearHoverTimer()
|
|
40
|
+
hoverTimer = setTimeout(() => sub.openSub(), 150)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function onPointerLeave(e: PointerEvent) {
|
|
44
|
+
clearHoverTimer()
|
|
45
|
+
// If the submenu is open, arm the grace area toward the content.
|
|
46
|
+
sub.onTriggerLeave(e)
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
function onKeyDown(e: KeyboardEvent) {
|
|
29
50
|
if (disabled) return
|
|
30
51
|
if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
|
|
@@ -35,7 +56,8 @@
|
|
|
35
56
|
// Space would also leak into content-level typeahead). Same pattern as
|
|
36
57
|
// dropdown-menu-sub-content.svelte for ArrowLeft/Escape.
|
|
37
58
|
e.stopPropagation()
|
|
38
|
-
|
|
59
|
+
// Keyboard open moves focus into the submenu (APG); pointer open never does.
|
|
60
|
+
sub.openSub({ focusContent: true })
|
|
39
61
|
}
|
|
40
62
|
}
|
|
41
63
|
|
|
@@ -48,12 +70,15 @@
|
|
|
48
70
|
<div
|
|
49
71
|
bind:this={el}
|
|
50
72
|
class="sp-dropdown-menu-item sp-dropdown-sub-trigger {className ?? ''}"
|
|
73
|
+
id={sub.triggerId}
|
|
51
74
|
role="menuitem"
|
|
52
75
|
aria-haspopup="menu"
|
|
53
76
|
aria-expanded={sub.open}
|
|
77
|
+
aria-controls={sub.open ? sub.contentId : undefined}
|
|
54
78
|
aria-disabled={disabled || undefined}
|
|
55
79
|
data-disabled={disabled || undefined}
|
|
56
80
|
data-roving-focus-item
|
|
81
|
+
data-text-value={textValue}
|
|
57
82
|
tabindex={-1}
|
|
58
83
|
onpointerenter={onPointerEnter}
|
|
59
84
|
onpointerleave={onPointerLeave}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAqF9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
|
|
@@ -1,31 +1,205 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { MenuSubHandle } from '../../../internal/menu-context/index.js'
|
|
2
3
|
import type { DropdownMenuSubProps } from '../types.js'
|
|
4
|
+
import { GraceAreaState } from '../../../internal/grace-area/index.js'
|
|
5
|
+
import { useId } from '../../../internal/helpers/index.js'
|
|
6
|
+
import {
|
|
7
|
+
createMenuContext,
|
|
8
|
+
getMenuContext,
|
|
9
|
+
setMenuContext,
|
|
10
|
+
} from '../../../internal/menu-context/index.js'
|
|
3
11
|
import { setDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
|
|
4
12
|
|
|
5
13
|
const { open: openProp = false, class: className, children }: DropdownMenuSubProps = $props()
|
|
6
14
|
|
|
15
|
+
// Parent menu level (root menu context, or the enclosing sub for nested subs).
|
|
16
|
+
// Captured BEFORE overriding the context for our own descendants.
|
|
17
|
+
const parentMenu = getMenuContext()
|
|
18
|
+
|
|
19
|
+
// Trigger ↔ content ARIA wiring (same id plumbing as the parent level:
|
|
20
|
+
// aria-controls on the trigger, aria-labelledby on the content).
|
|
21
|
+
const triggerId = useId('sp-dm-sub-trigger')
|
|
22
|
+
const contentId = useId('sp-dm-sub-content')
|
|
23
|
+
|
|
7
24
|
let open = $state(openProp)
|
|
8
25
|
let triggerEl: HTMLElement | null = $state(null)
|
|
26
|
+
let contentEl: HTMLElement | null = $state(null)
|
|
27
|
+
let focusContentOnOpen = $state(false)
|
|
28
|
+
|
|
29
|
+
// Grace area between the trigger and the sub content: when the pointer
|
|
30
|
+
// leaves the trigger toward the content, the triangular polygon from the
|
|
31
|
+
// exit point to the content's near edge keeps the submenu open during the
|
|
32
|
+
// diagonal travel (same layer as tooltip-trigger / link-preview-trigger).
|
|
33
|
+
const graceArea = new GraceAreaState({
|
|
34
|
+
get triggerElement() {
|
|
35
|
+
return triggerEl
|
|
36
|
+
},
|
|
37
|
+
get contentElement() {
|
|
38
|
+
return contentEl
|
|
39
|
+
},
|
|
40
|
+
get enabled() {
|
|
41
|
+
return open
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
let graceCleanup: (() => void) | null = null
|
|
46
|
+
|
|
47
|
+
function stopGraceTracking() {
|
|
48
|
+
if (graceCleanup) {
|
|
49
|
+
graceCleanup()
|
|
50
|
+
graceCleanup = null
|
|
51
|
+
}
|
|
52
|
+
graceArea.reset()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ownsTarget(target: EventTarget | null): boolean {
|
|
56
|
+
if (!(target instanceof Node)) return false
|
|
57
|
+
return Boolean(triggerEl?.contains(target) || contentEl?.contains(target))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startGraceTracking(exitPoint: { x: number; y: number }) {
|
|
61
|
+
stopGraceTracking()
|
|
62
|
+
graceArea.setPointerExit(exitPoint)
|
|
63
|
+
|
|
64
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
65
|
+
// Pointer reached the sub content (or came back on the trigger):
|
|
66
|
+
// resolved — stay open and stop tracking.
|
|
67
|
+
if (ownsTarget(e.target)) {
|
|
68
|
+
stopGraceTracking()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
// Still traveling through the grace polygon: stay open, keep tracking.
|
|
72
|
+
if (graceArea.isPointInGraceArea({ x: e.clientX, y: e.clientY })) return
|
|
73
|
+
// Left trigger, polygon and content: close.
|
|
74
|
+
stopGraceTracking()
|
|
75
|
+
closeSub()
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener('pointermove', onPointerMove)
|
|
78
|
+
|
|
79
|
+
// Safety: if the pointer never resolves the travel (e.g. stalls inside
|
|
80
|
+
// the polygon), close after a timeout — mirrors the tooltip/link-preview
|
|
81
|
+
// grace wiring, with the timer cleaned up rigorously on resolution.
|
|
82
|
+
const safetyTimer = setTimeout(() => {
|
|
83
|
+
stopGraceTracking()
|
|
84
|
+
closeSub()
|
|
85
|
+
}, 500)
|
|
86
|
+
|
|
87
|
+
graceCleanup = () => {
|
|
88
|
+
document.removeEventListener('pointermove', onPointerMove)
|
|
89
|
+
clearTimeout(safetyTimer)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle registered on the parent menu level (see the registration effect
|
|
94
|
+
// below) so sibling rows (plain items, checkbox items, other sub triggers)
|
|
95
|
+
// close this submenu on pointerenter — unless they own it or the pointer is
|
|
96
|
+
// traveling through the grace area. `openSub` also passes it to
|
|
97
|
+
// `closeOtherSubs` to enforce the single-open-sub invariant.
|
|
98
|
+
const handle: MenuSubHandle = {
|
|
99
|
+
ownsTarget,
|
|
100
|
+
isPointerInGrace(point) {
|
|
101
|
+
return graceArea.isPointInGraceArea(point)
|
|
102
|
+
},
|
|
103
|
+
closeSub() {
|
|
104
|
+
closeSub()
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function openSub(options?: { focusContent?: boolean }) {
|
|
109
|
+
// Single open sub per level (Radix semantics): opening this sub
|
|
110
|
+
// force-closes every sibling at the parent level. Covers the paths that
|
|
111
|
+
// never go through `onItemEnter` pointer coordination — keyboard opens
|
|
112
|
+
// (ArrowRight/Enter/Space) — and a hover-open fired from inside a
|
|
113
|
+
// sibling's grace polygon (the sibling was spared by the grace check but
|
|
114
|
+
// must yield once this sub actually opens). Closing the sibling also
|
|
115
|
+
// cancels its grace tracking and safety timer (`closeSub` →
|
|
116
|
+
// `stopGraceTracking`).
|
|
117
|
+
parentMenu?.closeOtherSubs(handle)
|
|
118
|
+
if (options?.focusContent) focusContentOnOpen = true
|
|
119
|
+
open = true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function closeSub(options?: { restoreFocus?: boolean }) {
|
|
123
|
+
stopGraceTracking()
|
|
124
|
+
focusContentOnOpen = false
|
|
125
|
+
// Mixed-modality guard: pointer paths close without asking for a focus
|
|
126
|
+
// restore, but if keyboard focus currently lives inside the sub content,
|
|
127
|
+
// unmounting it would drop focus to <body> — arrow keys and typeahead go
|
|
128
|
+
// dead because the parent menu's keydown handler lives on its content
|
|
129
|
+
// element (the exact #197 dead-end). Capture BEFORE flipping `open`
|
|
130
|
+
// (the close effect nulls contentEl on unmount).
|
|
131
|
+
const focusWasInside = Boolean(
|
|
132
|
+
contentEl && document.activeElement && contentEl.contains(document.activeElement),
|
|
133
|
+
)
|
|
134
|
+
open = false
|
|
135
|
+
// Restore focus to the trigger (a row of the parent menu, so parent
|
|
136
|
+
// roving/typeahead keep working) when explicitly requested (keyboard
|
|
137
|
+
// paths) or whenever focus would otherwise be orphaned.
|
|
138
|
+
if (options?.restoreFocus || focusWasInside) triggerEl?.focus()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
$effect(() => {
|
|
142
|
+
if (!parentMenu) return
|
|
143
|
+
return parentMenu.registerSub(handle)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Stop grace tracking whenever the submenu closes or unmounts (no leaked
|
|
147
|
+
// document listeners / timers).
|
|
148
|
+
$effect(() => {
|
|
149
|
+
if (open) {
|
|
150
|
+
return () => stopGraceTracking()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Our own descendants (rows inside the sub content) coordinate nested
|
|
155
|
+
// submenus against THIS level, while `close()` still dismisses the whole
|
|
156
|
+
// menu tree via the parent context.
|
|
157
|
+
setMenuContext(
|
|
158
|
+
createMenuContext({
|
|
159
|
+
close() {
|
|
160
|
+
parentMenu?.close()
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
9
164
|
|
|
10
165
|
setDropdownMenuSubContext({
|
|
166
|
+
triggerId,
|
|
167
|
+
contentId,
|
|
11
168
|
get open() {
|
|
12
169
|
return open
|
|
13
170
|
},
|
|
14
171
|
get triggerEl() {
|
|
15
172
|
return triggerEl
|
|
16
173
|
},
|
|
17
|
-
|
|
18
|
-
|
|
174
|
+
get contentEl() {
|
|
175
|
+
return contentEl
|
|
19
176
|
},
|
|
20
|
-
|
|
21
|
-
|
|
177
|
+
get focusContentOnOpen() {
|
|
178
|
+
return focusContentOnOpen
|
|
22
179
|
},
|
|
23
|
-
|
|
24
|
-
open
|
|
180
|
+
toggle() {
|
|
181
|
+
if (open) closeSub()
|
|
182
|
+
else openSub()
|
|
183
|
+
},
|
|
184
|
+
openSub,
|
|
185
|
+
closeSub,
|
|
186
|
+
consumeFocusContentRequest() {
|
|
187
|
+
focusContentOnOpen = false
|
|
25
188
|
},
|
|
26
189
|
setTriggerEl(el: HTMLElement) {
|
|
27
190
|
triggerEl = el
|
|
28
191
|
},
|
|
192
|
+
setContentEl(el: HTMLElement | null) {
|
|
193
|
+
contentEl = el
|
|
194
|
+
},
|
|
195
|
+
onTriggerEnter(event: PointerEvent) {
|
|
196
|
+
stopGraceTracking()
|
|
197
|
+
parentMenu?.onItemEnter(event)
|
|
198
|
+
},
|
|
199
|
+
onTriggerLeave(event: PointerEvent) {
|
|
200
|
+
if (!open || !contentEl) return
|
|
201
|
+
startGraceTracking({ x: event.clientX, y: event.clientY })
|
|
202
|
+
},
|
|
29
203
|
})
|
|
30
204
|
</script>
|
|
31
205
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dropdown-menu-sub.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dropdown-menu-sub.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA2NvD,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -14,13 +14,59 @@ export declare function createDropdownMenuIds(): {
|
|
|
14
14
|
triggerId: string;
|
|
15
15
|
};
|
|
16
16
|
export interface DropdownMenuSubContext {
|
|
17
|
+
/** Id of the sub trigger row (aria-labelledby target of the sub content). */
|
|
18
|
+
readonly triggerId: string;
|
|
19
|
+
/** Id of the sub content (aria-controls target of the sub trigger). */
|
|
20
|
+
readonly contentId: string;
|
|
17
21
|
readonly open: boolean;
|
|
18
22
|
readonly triggerEl: HTMLElement | null;
|
|
23
|
+
readonly contentEl: HTMLElement | null;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the next content mount should focus its first item
|
|
26
|
+
* (set by keyboard open — APG focus-into-sub; never by pointer open).
|
|
27
|
+
*/
|
|
28
|
+
readonly focusContentOnOpen: boolean;
|
|
19
29
|
toggle: () => void;
|
|
20
|
-
openSub: (
|
|
21
|
-
|
|
30
|
+
openSub: (options?: {
|
|
31
|
+
focusContent?: boolean;
|
|
32
|
+
}) => void;
|
|
33
|
+
closeSub: (options?: {
|
|
34
|
+
restoreFocus?: boolean;
|
|
35
|
+
}) => void;
|
|
36
|
+
/** Marks the pending focus-into-sub request as handled. */
|
|
37
|
+
consumeFocusContentRequest: () => void;
|
|
22
38
|
setTriggerEl: (el: HTMLElement) => void;
|
|
39
|
+
setContentEl: (el: HTMLElement | null) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Pointer entered the sub trigger: closes sibling submenus at the parent
|
|
42
|
+
* level and cancels any pending grace-area close tracking.
|
|
43
|
+
*/
|
|
44
|
+
onTriggerEnter: (event: PointerEvent) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Pointer left the sub trigger: if the submenu is open, starts grace-area
|
|
47
|
+
* tracking — the submenu stays open while the pointer travels through the
|
|
48
|
+
* grace polygon toward the content, and closes otherwise.
|
|
49
|
+
*/
|
|
50
|
+
onTriggerLeave: (event: PointerEvent) => void;
|
|
23
51
|
}
|
|
24
52
|
export declare function setDropdownMenuSubContext(state: DropdownMenuSubContext): void;
|
|
25
53
|
export declare function getDropdownMenuSubContext(): DropdownMenuSubContext;
|
|
54
|
+
/**
|
|
55
|
+
* The single-choice contract shared by a `RadioGroup` and its `RadioItem`
|
|
56
|
+
* rows. `value` is a GETTER, never a snapshot: the group owns a `$bindable`
|
|
57
|
+
* prop, and a row reading `ctx.value` while rendering must see the current
|
|
58
|
+
* selection — including one changed from OUTSIDE the menu.
|
|
59
|
+
*/
|
|
60
|
+
export interface DropdownMenuRadioGroupContext {
|
|
61
|
+
readonly value: string | undefined;
|
|
62
|
+
/** Selects `value`. A no-op — and no notification — when already selected. */
|
|
63
|
+
select: (value: string) => void;
|
|
64
|
+
}
|
|
65
|
+
export declare function setDropdownMenuRadioGroupContext(state: DropdownMenuRadioGroupContext): void;
|
|
66
|
+
/**
|
|
67
|
+
* Throws a NAMED error instead of returning `undefined`: an orphan
|
|
68
|
+
* `RadioItem` would otherwise fail later with an opaque property-access
|
|
69
|
+
* TypeError, at a point that no longer names the mistake.
|
|
70
|
+
*/
|
|
71
|
+
export declare function getDropdownMenuRadioGroupContext(): DropdownMenuRadioGroupContext;
|
|
26
72
|
//# sourceMappingURL=dropdown-menu.svelte.d.ts.map
|