ajo-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
package/src/menu.tsx
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import type { Children, IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler, controlled, dom, frame, listen, roving, statefulRootAttrs as rootAttrs, typeahead } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import {
|
|
5
|
+
cluster,
|
|
6
|
+
focusEdge,
|
|
7
|
+
isolateMenuComposition,
|
|
8
|
+
isolateMenuInvocation,
|
|
9
|
+
menuComposition,
|
|
10
|
+
menuItems,
|
|
11
|
+
provideMenuInvocation,
|
|
12
|
+
SURFACE_SELECTOR,
|
|
13
|
+
surfaceItems,
|
|
14
|
+
type MenuBranch,
|
|
15
|
+
type MenuCluster,
|
|
16
|
+
type MenuInvocationFocus,
|
|
17
|
+
} from './menu-cluster'
|
|
18
|
+
import { contentAttrs, popup, type PopupView } from './popup'
|
|
19
|
+
import type { PositionReference, ReservedPositionArg } from './position'
|
|
20
|
+
import type { FixedArgs, OmitArg, PopupPosition } from './utils'
|
|
21
|
+
import { flag, popupStyle, text, triggerAttrs } from './utils'
|
|
22
|
+
export type { PopupPlacement, PopupPosition } from './utils'
|
|
23
|
+
|
|
24
|
+
/** Semantic tone applied to an actionable menu item. */
|
|
25
|
+
export type MenuVariant = 'default' | 'danger'
|
|
26
|
+
|
|
27
|
+
/** Arguments for the Menu open-state provider. */
|
|
28
|
+
export type MenuArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'onchange' | ReservedPositionArg> & PopupPosition & {
|
|
29
|
+
/** Controlled open state. */
|
|
30
|
+
open?: boolean
|
|
31
|
+
/** Initial open state for uncontrolled usage. */
|
|
32
|
+
defaultOpen?: boolean
|
|
33
|
+
/** Disable the trigger and item activation. */
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
/** Called whenever the menu opens or closes. */
|
|
36
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
37
|
+
/** Additional UnoCSS classes for the root. */
|
|
38
|
+
class?: string
|
|
39
|
+
}> & FixedArgs<'onchange' | ReservedPositionArg>
|
|
40
|
+
|
|
41
|
+
/** Arguments for the button that toggles a Menu. */
|
|
42
|
+
export type MenuTriggerArgs = WithChildren<IntrinsicElements['button'] & {
|
|
43
|
+
/** Additional UnoCSS classes. */
|
|
44
|
+
class?: string
|
|
45
|
+
}>
|
|
46
|
+
|
|
47
|
+
/** Arguments for the Menu surface; positioning and semantics belong to Menu. */
|
|
48
|
+
export type MenuContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'aria-labelledby' | 'hidden' | 'id' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
|
|
49
|
+
/** Additional UnoCSS classes. */
|
|
50
|
+
class?: string
|
|
51
|
+
/** Inline CSS declarations composed with live positioning styles. */
|
|
52
|
+
style?: string
|
|
53
|
+
}> & FixedArgs<'aria-labelledby' | 'gap' | 'hidden' | 'id' | 'placement' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
|
|
54
|
+
|
|
55
|
+
/** Arguments for an actionable item in a Menu. */
|
|
56
|
+
export type MenuItemArgs = WithChildren<IntrinsicElements['div'] & {
|
|
57
|
+
/** Left-indent text for iconless groups. */
|
|
58
|
+
inset?: boolean
|
|
59
|
+
/** Disable activation. */
|
|
60
|
+
disabled?: boolean
|
|
61
|
+
/** Called after click/key activation before the menu closes. Prevent default to keep it open. */
|
|
62
|
+
onSelect?: (event: Event) => void
|
|
63
|
+
/** Plain-text label used for typeahead. */
|
|
64
|
+
textValue?: string
|
|
65
|
+
/** Visual tone. */
|
|
66
|
+
variant?: MenuVariant
|
|
67
|
+
/** Additional UnoCSS classes. */
|
|
68
|
+
class?: string
|
|
69
|
+
}>
|
|
70
|
+
|
|
71
|
+
/** Arguments for a checkable item in a Menu. */
|
|
72
|
+
export type MenuCheckboxItemArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'checked'> & {
|
|
73
|
+
/** Controlled checked state. */
|
|
74
|
+
checked?: boolean
|
|
75
|
+
/** Disable activation. */
|
|
76
|
+
disabled?: boolean
|
|
77
|
+
/** Called whenever checked changes. */
|
|
78
|
+
onCheckedChange?: (checked: boolean, event: Event) => void
|
|
79
|
+
/** Plain-text label used for typeahead. */
|
|
80
|
+
textValue?: string
|
|
81
|
+
/** Additional UnoCSS classes. */
|
|
82
|
+
class?: string
|
|
83
|
+
indicatorClass?: string
|
|
84
|
+
indicatorIconClass?: string
|
|
85
|
+
}>
|
|
86
|
+
|
|
87
|
+
/** Arguments for a single-selection group of menu radio items. */
|
|
88
|
+
export type MenuRadioGroupArgs = WithChildren<IntrinsicElements['div'] & {
|
|
89
|
+
/** Controlled selected value. */
|
|
90
|
+
value?: string
|
|
91
|
+
/** Initial selected value for uncontrolled usage. */
|
|
92
|
+
defaultValue?: string
|
|
93
|
+
/** Called whenever selected value changes. */
|
|
94
|
+
onValueChange?: (value: string, event: Event) => void
|
|
95
|
+
/** Additional UnoCSS classes. */
|
|
96
|
+
class?: string
|
|
97
|
+
}>
|
|
98
|
+
|
|
99
|
+
/** Arguments for one value-bearing item in a menu radio group. */
|
|
100
|
+
export type MenuRadioItemArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'value'> & {
|
|
101
|
+
/** Item value used by the parent radio group. */
|
|
102
|
+
value: string
|
|
103
|
+
/** Disable activation. */
|
|
104
|
+
disabled?: boolean
|
|
105
|
+
/** Plain-text label used for typeahead. */
|
|
106
|
+
textValue?: string
|
|
107
|
+
/** Additional UnoCSS classes. */
|
|
108
|
+
class?: string
|
|
109
|
+
indicatorClass?: string
|
|
110
|
+
indicatorIconClass?: string
|
|
111
|
+
}>
|
|
112
|
+
|
|
113
|
+
/** Arguments for a non-interactive label inside menu content. */
|
|
114
|
+
export type MenuLabelArgs = WithChildren<IntrinsicElements['div'] & {
|
|
115
|
+
/** Left-indent text for iconless groups. */
|
|
116
|
+
inset?: boolean
|
|
117
|
+
/** Additional UnoCSS classes. */
|
|
118
|
+
class?: string
|
|
119
|
+
}>
|
|
120
|
+
|
|
121
|
+
/** Arguments for a semantic group of related menu items. */
|
|
122
|
+
export type MenuGroupArgs = WithChildren<IntrinsicElements['div'] & {
|
|
123
|
+
/** Additional UnoCSS classes. */
|
|
124
|
+
class?: string
|
|
125
|
+
}>
|
|
126
|
+
|
|
127
|
+
/** Arguments for a visual separator between menu groups. */
|
|
128
|
+
export type MenuSeparatorArgs = IntrinsicElements['div'] & {
|
|
129
|
+
/** Additional UnoCSS classes. */
|
|
130
|
+
class?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Arguments for a shortcut hint rendered beside a menu item. */
|
|
134
|
+
export type MenuShortcutArgs = WithChildren<IntrinsicElements['span'] & {
|
|
135
|
+
/** Additional UnoCSS classes. */
|
|
136
|
+
class?: string
|
|
137
|
+
}>
|
|
138
|
+
|
|
139
|
+
/** Arguments for a nested Menu open-state provider. */
|
|
140
|
+
export type MenuSubArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'gap' | 'onchange' | 'placement' | ReservedPositionArg> & {
|
|
141
|
+
/** Controlled open state. */
|
|
142
|
+
open?: boolean
|
|
143
|
+
/** Initial open state for uncontrolled usage. */
|
|
144
|
+
defaultOpen?: boolean
|
|
145
|
+
/** Called whenever the submenu opens or closes. */
|
|
146
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
147
|
+
}> & FixedArgs<'gap' | 'onchange' | 'placement' | ReservedPositionArg>
|
|
148
|
+
|
|
149
|
+
/** Arguments for the item that opens a nested Menu. */
|
|
150
|
+
export type MenuSubTriggerArgs = MenuItemArgs & {
|
|
151
|
+
iconClass?: string
|
|
152
|
+
}
|
|
153
|
+
/** Arguments for a nested Menu surface with system-owned positioning and semantics. */
|
|
154
|
+
export type MenuSubContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'aria-labelledby' | 'hidden' | 'id' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
|
|
155
|
+
/** Additional UnoCSS classes. */
|
|
156
|
+
class?: string
|
|
157
|
+
/** Inline CSS declarations composed with live positioning styles. */
|
|
158
|
+
style?: string
|
|
159
|
+
}> & FixedArgs<'aria-labelledby' | 'gap' | 'hidden' | 'id' | 'placement' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
|
|
160
|
+
|
|
161
|
+
/** Shared menu state private to Menu parts. */
|
|
162
|
+
type MenuContextValue = {
|
|
163
|
+
adoptTriggerId: PopupView['adoptTriggerId']
|
|
164
|
+
close: (event?: Event) => void
|
|
165
|
+
contentId: string
|
|
166
|
+
contentStyle: PopupView['contentStyle']
|
|
167
|
+
disabled: boolean
|
|
168
|
+
dismiss: (event: Event) => void
|
|
169
|
+
open: boolean
|
|
170
|
+
setContent: (element: HTMLDivElement | null) => void
|
|
171
|
+
setOpen: (open: boolean, event?: Event, focus?: 'first' | 'last') => void
|
|
172
|
+
setTrigger: (element: HTMLButtonElement | null) => void
|
|
173
|
+
triggerId: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type RadioContextValue = {
|
|
177
|
+
change: (value: string, event: Event) => void
|
|
178
|
+
value: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type SubContextValue = {
|
|
182
|
+
adoptTriggerId: PopupView['adoptTriggerId']
|
|
183
|
+
branch: MenuBranch
|
|
184
|
+
close: (event?: Event) => void
|
|
185
|
+
contentId: string
|
|
186
|
+
contentStyle: PopupView['contentStyle']
|
|
187
|
+
open: boolean
|
|
188
|
+
setContent: (element: HTMLDivElement | null) => void
|
|
189
|
+
setOpen: (open: boolean, event?: Event, focus?: boolean) => void
|
|
190
|
+
setTrigger: (element: HTMLElement | null) => void
|
|
191
|
+
triggerId: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type LevelContextValue = {
|
|
195
|
+
cluster: MenuCluster
|
|
196
|
+
content: () => HTMLElement | null
|
|
197
|
+
open: () => boolean
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const MenuContext = context<MenuContextValue | null>(null)
|
|
201
|
+
const RadioContext = context<RadioContextValue | null>(null)
|
|
202
|
+
const SubContext = context<SubContextValue | null>(null)
|
|
203
|
+
const LevelContext = context<LevelContextValue | null>(null)
|
|
204
|
+
|
|
205
|
+
const pointerHighlight = (
|
|
206
|
+
disabled: boolean,
|
|
207
|
+
closeSubmenus?: MenuCluster['close'],
|
|
208
|
+
keep?: MenuBranch,
|
|
209
|
+
) => (event: Event) => {
|
|
210
|
+
if (disabled) return
|
|
211
|
+
const target = event.currentTarget as HTMLElement
|
|
212
|
+
const content = target.closest<HTMLElement>(SURFACE_SELECTOR)
|
|
213
|
+
menuItems.focusItem(content, event.currentTarget as HTMLElement)
|
|
214
|
+
closeSubmenus?.(event, keep)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const MenuRoot: Stateful<MenuArgs> = function* ({ defaultOpen, open }) {
|
|
218
|
+
const composition = menuComposition()
|
|
219
|
+
const contextComposition = composition?.profile === 'context' ? composition : null
|
|
220
|
+
const ownerDocument = dom(this) ? this.ownerDocument : null
|
|
221
|
+
const node = (value: unknown): value is Node => {
|
|
222
|
+
const view = ownerDocument?.defaultView
|
|
223
|
+
return Boolean(view && value instanceof view.Node)
|
|
224
|
+
}
|
|
225
|
+
const submenus = cluster()
|
|
226
|
+
let contextSource: HTMLElement | null = null
|
|
227
|
+
let disabled = false
|
|
228
|
+
let focusRestore = 0
|
|
229
|
+
let geometryReady = false
|
|
230
|
+
let onOpenChange: MenuArgs['onOpenChange']
|
|
231
|
+
type MenuFocus = MenuInvocationFocus | 'last'
|
|
232
|
+
let pendingFocus: MenuFocus | undefined
|
|
233
|
+
let menu: PopupView<HTMLButtonElement, HTMLDivElement>
|
|
234
|
+
const commitMenubarFocus = frame(() => {
|
|
235
|
+
if (composition?.profile === 'menubar' && composition.ackFocus()) {
|
|
236
|
+
focusEdge(menu.content, 'first')
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
this.signal.addEventListener('abort', commitMenubarFocus.cancel)
|
|
240
|
+
|
|
241
|
+
menu = popup<HTMLButtonElement, HTMLDivElement>(this, {
|
|
242
|
+
prefix: 'menu',
|
|
243
|
+
profile: composition?.profile ?? 'menu',
|
|
244
|
+
initialOpen: Boolean(open ?? defaultOpen),
|
|
245
|
+
disabled: () => disabled,
|
|
246
|
+
onOpenChange: (next, event) => onOpenChange?.(next, event),
|
|
247
|
+
reference: view => view.reference ?? view.trigger,
|
|
248
|
+
source: view => contextComposition
|
|
249
|
+
? contextSource
|
|
250
|
+
: view.trigger ?? (dom(view.reference) ? view.reference as HTMLElement : null),
|
|
251
|
+
reopenOnReferenceChange: Boolean(contextComposition),
|
|
252
|
+
referenceHidden: 'close',
|
|
253
|
+
dismiss: {
|
|
254
|
+
prevent: true,
|
|
255
|
+
outside: true,
|
|
256
|
+
onDismiss: event => {
|
|
257
|
+
if (event.type === 'keydown') close(event)
|
|
258
|
+
else setOpen(false, event)
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
onPosition: () => {
|
|
262
|
+
geometryReady = true
|
|
263
|
+
if (pendingFocus === 'content') menu.content?.focus()
|
|
264
|
+
else if (pendingFocus) focusEdge(menu.content, pendingFocus)
|
|
265
|
+
else if (composition?.profile === 'menubar') commitMenubarFocus()
|
|
266
|
+
pendingFocus = undefined
|
|
267
|
+
},
|
|
268
|
+
onSync: opened => {
|
|
269
|
+
if (!opened) {
|
|
270
|
+
commitMenubarFocus.cancel()
|
|
271
|
+
geometryReady = false
|
|
272
|
+
menuItems.clearHighlight(menu.content)
|
|
273
|
+
submenus.close()
|
|
274
|
+
pendingFocus = undefined
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const focusWhenReady = (focus: MenuFocus) => {
|
|
280
|
+
if (geometryReady) {
|
|
281
|
+
if (focus === 'content') menu.content?.focus()
|
|
282
|
+
else focusEdge(menu.content, focus)
|
|
283
|
+
}
|
|
284
|
+
else pendingFocus = focus
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const setOpen = (next: boolean, event?: Event, focus?: MenuFocus) => {
|
|
288
|
+
if (disabled && next) return
|
|
289
|
+
if (next) focusRestore++
|
|
290
|
+
if (next === menu.open) {
|
|
291
|
+
if (next && focus) focusWhenReady(focus)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!next) {
|
|
296
|
+
submenus.close(event)
|
|
297
|
+
pendingFocus = undefined
|
|
298
|
+
}
|
|
299
|
+
else geometryReady = false
|
|
300
|
+
if (next && focus) pendingFocus = focus
|
|
301
|
+
menu.setOpen(next, event)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const invoke = (
|
|
305
|
+
reference: PositionReference,
|
|
306
|
+
source: HTMLElement,
|
|
307
|
+
event: Event,
|
|
308
|
+
focus: MenuInvocationFocus,
|
|
309
|
+
) => {
|
|
310
|
+
geometryReady = false
|
|
311
|
+
pendingFocus = focus
|
|
312
|
+
contextSource = source
|
|
313
|
+
const changed = menu.reference !== reference
|
|
314
|
+
submenus.close(event)
|
|
315
|
+
menu.setReference(reference)
|
|
316
|
+
if (menu.open) {
|
|
317
|
+
// A ContextMenu virtual point mutates coordinates without changing
|
|
318
|
+
// identity; no observer can detect that same-reference update.
|
|
319
|
+
if (!changed) menu.update()
|
|
320
|
+
} else {
|
|
321
|
+
menu.setOpen(true, event)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const close = (event?: Event) => {
|
|
326
|
+
const wasOpen = menu.open
|
|
327
|
+
const restore = ++focusRestore
|
|
328
|
+
setOpen(false, event)
|
|
329
|
+
if (contextComposition) {
|
|
330
|
+
if (wasOpen && !menu.open) contextComposition.restoreFocus()
|
|
331
|
+
} else queueMicrotask(() => {
|
|
332
|
+
if (restore === focusRestore && wasOpen && !menu.open) menu.trigger?.focus()
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const dismiss = (event: Event) => {
|
|
337
|
+
const target = event.target
|
|
338
|
+
const reference = menu.reference
|
|
339
|
+
const inside = node(target) && Boolean(
|
|
340
|
+
this.contains(target)
|
|
341
|
+
|| menu.trigger?.contains(target)
|
|
342
|
+
|| menu.content?.contains(target)
|
|
343
|
+
|| (dom(reference) && reference.contains(target)),
|
|
344
|
+
)
|
|
345
|
+
if (inside) submenus.prune(target, event)
|
|
346
|
+
else setOpen(false, event)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Keyboard movement stays inside the surface that owns focus: an open
|
|
350
|
+
// submenu cycles its own items, never the parent menu's.
|
|
351
|
+
const focusedSurface = () => {
|
|
352
|
+
const active = ownerDocument?.activeElement
|
|
353
|
+
const surface = dom(active) ? active.closest<HTMLElement>(SURFACE_SELECTOR) : null
|
|
354
|
+
return surface ?? menu.content
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const nav = roving(this, {
|
|
358
|
+
items: () => surfaceItems(focusedSurface()),
|
|
359
|
+
onMove: target => menuItems.focusItem(focusedSurface(), target),
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
const ta = typeahead(this, {
|
|
363
|
+
items: () => surfaceItems(focusedSurface()),
|
|
364
|
+
onMatch: target => menuItems.focusItem(focusedSurface(), target),
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
listen(this, 'keydown', (event: KeyboardEvent) => {
|
|
368
|
+
if (event.defaultPrevented) return
|
|
369
|
+
const target = event.target as HTMLElement | null
|
|
370
|
+
if (!target?.closest('[data-menu-trigger="true"],[data-menu-content="true"]')) return
|
|
371
|
+
|
|
372
|
+
if (target.closest('[data-menu-trigger="true"]')) {
|
|
373
|
+
// A pointer click may have opened the menu without moving focus;
|
|
374
|
+
// arrows from the still-focused trigger enter it.
|
|
375
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
376
|
+
event.preventDefault()
|
|
377
|
+
setOpen(true, event, 'first')
|
|
378
|
+
} else if (event.key === 'ArrowUp') {
|
|
379
|
+
event.preventDefault()
|
|
380
|
+
setOpen(true, event, 'last')
|
|
381
|
+
}
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (nav.handle(event)) {
|
|
386
|
+
return
|
|
387
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
388
|
+
const item = menuItems.item(event)
|
|
389
|
+
if (!item) return
|
|
390
|
+
event.preventDefault()
|
|
391
|
+
item.click()
|
|
392
|
+
} else if (ta.handle(event)) {
|
|
393
|
+
event.preventDefault()
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
for (const args of this) {
|
|
398
|
+
disabled = Boolean(args.disabled)
|
|
399
|
+
onOpenChange = args.onOpenChange
|
|
400
|
+
const wasOpen = menu.open
|
|
401
|
+
const opened = menu.sync(args.open != null ? Boolean(args.open) : null, {
|
|
402
|
+
placement: args.placement,
|
|
403
|
+
gap: args.gap,
|
|
404
|
+
})
|
|
405
|
+
if (!wasOpen && opened) focusRestore++
|
|
406
|
+
if (wasOpen && !opened) {
|
|
407
|
+
// A controlled close can beat the first geometry commit, so invalidate
|
|
408
|
+
// focus intent here instead of relying only on popup.onSync(false).
|
|
409
|
+
geometryReady = false
|
|
410
|
+
commitMenubarFocus.cancel()
|
|
411
|
+
pendingFocus = undefined
|
|
412
|
+
submenus.close()
|
|
413
|
+
}
|
|
414
|
+
// Composition applies to exactly this root; arbitrary nested Menu roots
|
|
415
|
+
// retain their own menu profile and interaction policy.
|
|
416
|
+
isolateMenuComposition()
|
|
417
|
+
isolateMenuInvocation()
|
|
418
|
+
|
|
419
|
+
MenuContext({
|
|
420
|
+
adoptTriggerId: menu.adoptTriggerId,
|
|
421
|
+
close,
|
|
422
|
+
contentId: menu.contentId,
|
|
423
|
+
contentStyle: menu.contentStyle,
|
|
424
|
+
disabled,
|
|
425
|
+
dismiss,
|
|
426
|
+
open: opened,
|
|
427
|
+
setContent: menu.setContent,
|
|
428
|
+
setOpen,
|
|
429
|
+
setTrigger: menu.setTrigger,
|
|
430
|
+
get triggerId() { return menu.triggerId },
|
|
431
|
+
})
|
|
432
|
+
if (contextComposition) provideMenuInvocation({
|
|
433
|
+
adoptTriggerId: menu.adoptTriggerId,
|
|
434
|
+
contentId: menu.contentId,
|
|
435
|
+
disabled,
|
|
436
|
+
invoke: (x, y, event, source, focus) =>
|
|
437
|
+
contextComposition.invoke(invoke, x, y, event, source, focus),
|
|
438
|
+
open: opened,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
LevelContext({ cluster: submenus, content: () => menu.content, open: () => menu.open })
|
|
442
|
+
yield <>{args.children}</>
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
/** Root provider for a menu. */
|
|
448
|
+
const Menu: Stateless<MenuArgs> = ({
|
|
449
|
+
children,
|
|
450
|
+
class: classes,
|
|
451
|
+
defaultOpen,
|
|
452
|
+
disabled,
|
|
453
|
+
gap,
|
|
454
|
+
onOpenChange,
|
|
455
|
+
open,
|
|
456
|
+
placement,
|
|
457
|
+
...attrs
|
|
458
|
+
}) => (
|
|
459
|
+
<MenuRoot
|
|
460
|
+
{...rootAttrs(attrs)}
|
|
461
|
+
defaultOpen={defaultOpen}
|
|
462
|
+
disabled={disabled}
|
|
463
|
+
gap={gap}
|
|
464
|
+
onOpenChange={onOpenChange}
|
|
465
|
+
open={open}
|
|
466
|
+
placement={placement}
|
|
467
|
+
attr:class={classes}
|
|
468
|
+
attr:data-slot="menu"
|
|
469
|
+
>
|
|
470
|
+
{children}
|
|
471
|
+
</MenuRoot>
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
/** Button that opens a Menu. */
|
|
475
|
+
const MenuTrigger: Stateless<MenuTriggerArgs> = ({
|
|
476
|
+
children,
|
|
477
|
+
'data-slot': slot = 'menu-trigger',
|
|
478
|
+
disabled,
|
|
479
|
+
id,
|
|
480
|
+
ref,
|
|
481
|
+
type = 'button',
|
|
482
|
+
'set:onclick': onClick,
|
|
483
|
+
...attrs
|
|
484
|
+
}) => {
|
|
485
|
+
const menu = MenuContext()
|
|
486
|
+
const disabledFlag = Boolean(disabled ?? menu?.disabled)
|
|
487
|
+
const adoptedId = menu?.adoptTriggerId(id)
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<button
|
|
491
|
+
{...attrs}
|
|
492
|
+
{...triggerAttrs({
|
|
493
|
+
controls: menu?.contentId,
|
|
494
|
+
expanded: Boolean(menu?.open),
|
|
495
|
+
haspopup: 'menu',
|
|
496
|
+
id: adoptedId ?? id,
|
|
497
|
+
open: Boolean(menu?.open),
|
|
498
|
+
ref,
|
|
499
|
+
setTrigger: menu?.setTrigger,
|
|
500
|
+
triggerId: menu?.triggerId,
|
|
501
|
+
})}
|
|
502
|
+
data-menu-trigger="true"
|
|
503
|
+
data-slot={slot}
|
|
504
|
+
disabled={disabledFlag}
|
|
505
|
+
set:onclick={(event: Event) => {
|
|
506
|
+
callHandler(onClick, event)
|
|
507
|
+
if (event.defaultPrevented) return
|
|
508
|
+
// Focus intent follows input modality: keyboard/AT activation
|
|
509
|
+
// (detail 0) focuses the first item; pointer clicks open the
|
|
510
|
+
// menu without moving focus (keyboard opens go through the
|
|
511
|
+
// root keydown handler, which preventDefaults the click).
|
|
512
|
+
menu?.setOpen(!menu.open, event, (event as MouseEvent).detail === 0 ? 'first' : undefined)
|
|
513
|
+
}}
|
|
514
|
+
type={type}
|
|
515
|
+
>
|
|
516
|
+
{children}
|
|
517
|
+
</button>
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Popover menu content. */
|
|
522
|
+
const MenuContent: Stateless<MenuContentArgs> = ({
|
|
523
|
+
children,
|
|
524
|
+
class: classes,
|
|
525
|
+
'data-slot': slot = 'menu-content',
|
|
526
|
+
ref,
|
|
527
|
+
style,
|
|
528
|
+
...attrs
|
|
529
|
+
}) => {
|
|
530
|
+
const menu = MenuContext()
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<div
|
|
534
|
+
{...attrs}
|
|
535
|
+
{...contentAttrs({
|
|
536
|
+
id: menu?.contentId,
|
|
537
|
+
open: Boolean(menu?.open),
|
|
538
|
+
ref,
|
|
539
|
+
setContent: menu?.setContent,
|
|
540
|
+
style: menu?.contentStyle(style) ?? popupStyle(style),
|
|
541
|
+
tabindex: '-1',
|
|
542
|
+
})}
|
|
543
|
+
aria-labelledby={menu?.triggerId}
|
|
544
|
+
class={classes}
|
|
545
|
+
data-menu-content="true"
|
|
546
|
+
data-slot={slot}
|
|
547
|
+
hidden={undefined}
|
|
548
|
+
role="menu"
|
|
549
|
+
>
|
|
550
|
+
{children}
|
|
551
|
+
</div>
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Group of menu items. */
|
|
556
|
+
const MenuGroup: Stateless<MenuGroupArgs> = ({ children, class: classes, 'data-slot': slot = 'menu-group', ...attrs }) => (
|
|
557
|
+
<div {...attrs} class={classes} data-slot={slot} role="group">
|
|
558
|
+
{children}
|
|
559
|
+
</div>
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
/** Non-interactive label inside a menu. */
|
|
563
|
+
const MenuLabel: Stateless<MenuLabelArgs> = ({
|
|
564
|
+
children,
|
|
565
|
+
class: classes,
|
|
566
|
+
'data-slot': slot = 'menu-label',
|
|
567
|
+
inset,
|
|
568
|
+
...attrs
|
|
569
|
+
}) => (
|
|
570
|
+
<div
|
|
571
|
+
{...attrs}
|
|
572
|
+
class={classes}
|
|
573
|
+
data-inset={flag(inset)}
|
|
574
|
+
data-slot={slot}
|
|
575
|
+
>
|
|
576
|
+
{children}
|
|
577
|
+
</div>
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
/** Single activation guard for every item kind: disabled check, composed onclick, then the kind's action. */
|
|
581
|
+
const activate = (
|
|
582
|
+
disabled: boolean,
|
|
583
|
+
onClick: unknown,
|
|
584
|
+
action: (event: Event) => void,
|
|
585
|
+
) => (event: Event) => {
|
|
586
|
+
if (disabled) return
|
|
587
|
+
callHandler(onClick, event)
|
|
588
|
+
if (event.defaultPrevented) return
|
|
589
|
+
action(event)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Standard menu action item. */
|
|
593
|
+
const MenuItem: Stateless<MenuItemArgs> = ({
|
|
594
|
+
children,
|
|
595
|
+
class: classes,
|
|
596
|
+
'data-slot': slot = 'menu-item',
|
|
597
|
+
disabled,
|
|
598
|
+
inset,
|
|
599
|
+
onSelect,
|
|
600
|
+
textValue,
|
|
601
|
+
variant = 'default',
|
|
602
|
+
'set:onclick': onClick,
|
|
603
|
+
...attrs
|
|
604
|
+
}) => {
|
|
605
|
+
const menu = MenuContext()
|
|
606
|
+
const level = LevelContext()
|
|
607
|
+
const disabledFlag = Boolean(disabled ?? menu?.disabled)
|
|
608
|
+
const label = textValue ?? text(children)
|
|
609
|
+
const highlight = pointerHighlight(disabledFlag, level?.cluster.close)
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<div
|
|
613
|
+
{...attrs}
|
|
614
|
+
{...menuItems.attrs({ disabled: disabledFlag, label })}
|
|
615
|
+
aria-disabled={flag(disabledFlag)}
|
|
616
|
+
class={classes}
|
|
617
|
+
data-inset={flag(inset)}
|
|
618
|
+
data-slot={slot}
|
|
619
|
+
data-variant={variant}
|
|
620
|
+
role="menuitem"
|
|
621
|
+
set:onclick={activate(disabledFlag, onClick, event => {
|
|
622
|
+
onSelect?.(event)
|
|
623
|
+
if (!event.defaultPrevented) menu?.close(event)
|
|
624
|
+
})}
|
|
625
|
+
set:onpointerenter={highlight}
|
|
626
|
+
set:onpointermove={highlight}
|
|
627
|
+
tabindex="-1"
|
|
628
|
+
>
|
|
629
|
+
{children}
|
|
630
|
+
</div>
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Shared row shell for checkbox/radio choice items; only role, checked source, and action differ. */
|
|
635
|
+
const choiceItem = (opts: {
|
|
636
|
+
attrs: Record<string, unknown>
|
|
637
|
+
checked: boolean
|
|
638
|
+
children: Children
|
|
639
|
+
class?: string
|
|
640
|
+
disabled: boolean
|
|
641
|
+
indicatorClass?: string
|
|
642
|
+
indicatorIconClass?: string
|
|
643
|
+
label: string
|
|
644
|
+
menu: MenuContextValue | null
|
|
645
|
+
action: (event: Event) => void
|
|
646
|
+
onClick: unknown
|
|
647
|
+
role: 'menuitemcheckbox' | 'menuitemradio'
|
|
648
|
+
slot: unknown
|
|
649
|
+
value?: string
|
|
650
|
+
}) => {
|
|
651
|
+
const highlight = pointerHighlight(opts.disabled, LevelContext()?.cluster.close)
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<div
|
|
655
|
+
{...opts.attrs}
|
|
656
|
+
{...menuItems.attrs({ disabled: opts.disabled, label: opts.label, value: opts.value })}
|
|
657
|
+
aria-checked={opts.checked ? 'true' : 'false'}
|
|
658
|
+
aria-disabled={flag(opts.disabled)}
|
|
659
|
+
class={opts.class}
|
|
660
|
+
data-checked={flag(opts.checked)}
|
|
661
|
+
data-slot={opts.slot}
|
|
662
|
+
role={opts.role}
|
|
663
|
+
set:onclick={activate(opts.disabled, opts.onClick, opts.action)}
|
|
664
|
+
set:onpointerenter={highlight}
|
|
665
|
+
set:onpointermove={highlight}
|
|
666
|
+
tabindex="-1"
|
|
667
|
+
>
|
|
668
|
+
<span class={opts.indicatorClass}>
|
|
669
|
+
{opts.checked ? <span aria-hidden="true" class={opts.indicatorIconClass} /> : null}
|
|
670
|
+
</span>
|
|
671
|
+
{opts.children}
|
|
672
|
+
</div>
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Checkable menu item. */
|
|
677
|
+
const MenuCheckboxItem: Stateless<MenuCheckboxItemArgs> = ({
|
|
678
|
+
checked,
|
|
679
|
+
children,
|
|
680
|
+
class: classes,
|
|
681
|
+
'data-slot': slot = 'menu-checkbox-item',
|
|
682
|
+
disabled,
|
|
683
|
+
indicatorClass,
|
|
684
|
+
indicatorIconClass,
|
|
685
|
+
onCheckedChange,
|
|
686
|
+
textValue,
|
|
687
|
+
'set:onclick': onClick,
|
|
688
|
+
...attrs
|
|
689
|
+
}) => {
|
|
690
|
+
const menu = MenuContext()
|
|
691
|
+
const checkedFlag = Boolean(checked)
|
|
692
|
+
|
|
693
|
+
return choiceItem({
|
|
694
|
+
attrs,
|
|
695
|
+
checked: checkedFlag,
|
|
696
|
+
children,
|
|
697
|
+
class: classes,
|
|
698
|
+
disabled: Boolean(disabled ?? menu?.disabled),
|
|
699
|
+
indicatorClass,
|
|
700
|
+
indicatorIconClass,
|
|
701
|
+
label: textValue ?? text(children),
|
|
702
|
+
menu,
|
|
703
|
+
action: event => onCheckedChange?.(!checkedFlag, event),
|
|
704
|
+
onClick,
|
|
705
|
+
role: 'menuitemcheckbox',
|
|
706
|
+
slot,
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const MenuRadioGroupRoot: Stateful<MenuRadioGroupArgs> = function* ({ defaultValue, value }) {
|
|
711
|
+
let onValueChange: MenuRadioGroupArgs['onValueChange']
|
|
712
|
+
const state = controlled<string>(this, {
|
|
713
|
+
fallback: String(value ?? defaultValue ?? ''),
|
|
714
|
+
onChange: (next, event) => onValueChange?.(next, event!),
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
const change = (next: string, event: Event) => {
|
|
718
|
+
state.set(next, event)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
for (const args of this) {
|
|
722
|
+
onValueChange = args.onValueChange
|
|
723
|
+
state.sync(args.value != null ? String(args.value ?? '') : undefined)
|
|
724
|
+
|
|
725
|
+
RadioContext({ change, value: state.value })
|
|
726
|
+
yield <>{args.children}</>
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
/** Radio group inside a menu. */
|
|
732
|
+
const MenuRadioGroup: Stateless<MenuRadioGroupArgs> = ({
|
|
733
|
+
children,
|
|
734
|
+
class: classes,
|
|
735
|
+
'data-slot': slot = 'menu-radio-group',
|
|
736
|
+
defaultValue,
|
|
737
|
+
onValueChange,
|
|
738
|
+
value,
|
|
739
|
+
...attrs
|
|
740
|
+
}) => (
|
|
741
|
+
<MenuRadioGroupRoot
|
|
742
|
+
{...rootAttrs(attrs)}
|
|
743
|
+
defaultValue={defaultValue}
|
|
744
|
+
onValueChange={onValueChange}
|
|
745
|
+
value={value}
|
|
746
|
+
attr:class={classes}
|
|
747
|
+
attr:data-slot={slot}
|
|
748
|
+
attr:role="group"
|
|
749
|
+
>
|
|
750
|
+
{children}
|
|
751
|
+
</MenuRadioGroupRoot>
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
/** Radio item inside a menu radio group. */
|
|
755
|
+
const MenuRadioItem: Stateless<MenuRadioItemArgs> = ({
|
|
756
|
+
children,
|
|
757
|
+
class: classes,
|
|
758
|
+
'data-slot': slot = 'menu-radio-item',
|
|
759
|
+
disabled,
|
|
760
|
+
indicatorClass,
|
|
761
|
+
indicatorIconClass,
|
|
762
|
+
textValue,
|
|
763
|
+
value,
|
|
764
|
+
'set:onclick': onClick,
|
|
765
|
+
...attrs
|
|
766
|
+
}) => {
|
|
767
|
+
const menu = MenuContext()
|
|
768
|
+
const group = RadioContext()
|
|
769
|
+
const itemValue = String(value)
|
|
770
|
+
|
|
771
|
+
return choiceItem({
|
|
772
|
+
attrs,
|
|
773
|
+
checked: group?.value === itemValue,
|
|
774
|
+
children,
|
|
775
|
+
class: classes,
|
|
776
|
+
disabled: Boolean(disabled ?? menu?.disabled),
|
|
777
|
+
indicatorClass,
|
|
778
|
+
indicatorIconClass,
|
|
779
|
+
label: textValue ?? text(children),
|
|
780
|
+
menu,
|
|
781
|
+
action: event => group?.change(itemValue, event),
|
|
782
|
+
onClick,
|
|
783
|
+
role: 'menuitemradio',
|
|
784
|
+
slot,
|
|
785
|
+
value: itemValue,
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Visual separator between menu groups. */
|
|
790
|
+
const MenuSeparator: Stateless<MenuSeparatorArgs> = ({ class: classes, 'data-slot': slot = 'menu-separator', ...attrs }) => (
|
|
791
|
+
<div
|
|
792
|
+
{...attrs}
|
|
793
|
+
class={classes}
|
|
794
|
+
data-slot={slot}
|
|
795
|
+
role="separator"
|
|
796
|
+
/>
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
/** Right-aligned shortcut hint inside a menu item. */
|
|
800
|
+
const MenuShortcut: Stateless<MenuShortcutArgs> = ({
|
|
801
|
+
children,
|
|
802
|
+
class: classes,
|
|
803
|
+
'data-slot': slot = 'menu-shortcut',
|
|
804
|
+
...attrs
|
|
805
|
+
}) => (
|
|
806
|
+
<span
|
|
807
|
+
{...attrs}
|
|
808
|
+
class={classes}
|
|
809
|
+
data-slot={slot}
|
|
810
|
+
>
|
|
811
|
+
{children}
|
|
812
|
+
</span>
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
const MenuSubRoot: Stateful<MenuSubArgs> = function* ({ defaultOpen, open }) {
|
|
816
|
+
const children = cluster()
|
|
817
|
+
const parent = LevelContext()
|
|
818
|
+
let focusRestore = 0
|
|
819
|
+
let geometryReady = false
|
|
820
|
+
let menu: MenuContextValue | null = null
|
|
821
|
+
let onOpenChange: MenuSubArgs['onOpenChange']
|
|
822
|
+
const parentCluster = parent?.cluster ?? null
|
|
823
|
+
let pendingFocus = false
|
|
824
|
+
let unregister: (() => void) | undefined
|
|
825
|
+
let branch: MenuBranch
|
|
826
|
+
let submenu: PopupView<HTMLElement, HTMLDivElement>
|
|
827
|
+
|
|
828
|
+
submenu = popup<HTMLElement, HTMLDivElement>(this, {
|
|
829
|
+
prefix: 'menu-sub',
|
|
830
|
+
profile: 'submenu',
|
|
831
|
+
initialOpen: Boolean(open ?? defaultOpen),
|
|
832
|
+
onOpenChange: (next, event) => onOpenChange?.(next, event),
|
|
833
|
+
reference: view => view.trigger,
|
|
834
|
+
source: view => view.trigger,
|
|
835
|
+
// A native top-layer child escapes older popup ancestors; limiting
|
|
836
|
+
// clipping to its direct parent surface avoids false referenceHidden.
|
|
837
|
+
referenceBoundary: () => parent?.content() ?? null,
|
|
838
|
+
referenceHidden: 'close',
|
|
839
|
+
dismiss: {
|
|
840
|
+
prevent: true,
|
|
841
|
+
outside: true,
|
|
842
|
+
onDismiss: event => {
|
|
843
|
+
if (event.type === 'keydown') close(event)
|
|
844
|
+
else if (menu) menu.dismiss(event)
|
|
845
|
+
else setOpen(false, event)
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
onSync: opened => {
|
|
849
|
+
if (opened) {
|
|
850
|
+
geometryReady = true
|
|
851
|
+
if (pendingFocus) focusEdge(submenu.content, 'first')
|
|
852
|
+
} else {
|
|
853
|
+
geometryReady = false
|
|
854
|
+
menuItems.clearHighlight(submenu.content)
|
|
855
|
+
children.close()
|
|
856
|
+
}
|
|
857
|
+
pendingFocus = false
|
|
858
|
+
},
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
const focusWhenReady = () => {
|
|
862
|
+
if (geometryReady) focusEdge(submenu.content, 'first')
|
|
863
|
+
else pendingFocus = true
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const setOpen = (next: boolean, event?: Event, focus = false) => {
|
|
867
|
+
if (next) focusRestore++
|
|
868
|
+
if (next === submenu.open) {
|
|
869
|
+
if (next && focus) focusWhenReady()
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (next) {
|
|
874
|
+
geometryReady = false
|
|
875
|
+
parentCluster?.close(event, branch)
|
|
876
|
+
} else {
|
|
877
|
+
children.close(event)
|
|
878
|
+
}
|
|
879
|
+
pendingFocus = next && focus
|
|
880
|
+
submenu.setOpen(next, event)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
branch = {
|
|
884
|
+
close: event => setOpen(false, event),
|
|
885
|
+
content: () => submenu.content,
|
|
886
|
+
prune: (target, event) => children.prune(target, event),
|
|
887
|
+
trigger: () => submenu.trigger,
|
|
888
|
+
}
|
|
889
|
+
unregister = parentCluster?.register(branch)
|
|
890
|
+
|
|
891
|
+
const close = (event?: Event) => {
|
|
892
|
+
const wasOpen = submenu.open
|
|
893
|
+
const restore = ++focusRestore
|
|
894
|
+
setOpen(false, event)
|
|
895
|
+
queueMicrotask(() => {
|
|
896
|
+
if (restore === focusRestore && wasOpen && !submenu.open) submenu.trigger?.focus()
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
this.signal.addEventListener('abort', () => unregister?.())
|
|
901
|
+
|
|
902
|
+
listen(this, 'keydown', (event: KeyboardEvent) => {
|
|
903
|
+
if (event.defaultPrevented) return
|
|
904
|
+
const target = event.target as HTMLElement | null
|
|
905
|
+
if (!target?.closest('[data-menu-sub-trigger="true"],[data-menu-sub-content="true"]')) return
|
|
906
|
+
if (event.key === 'ArrowRight' && target.matches('[data-menu-sub-trigger="true"]')) {
|
|
907
|
+
event.preventDefault()
|
|
908
|
+
setOpen(true, event, true)
|
|
909
|
+
} else if (event.key === 'ArrowLeft') {
|
|
910
|
+
// While closed ArrowLeft belongs to an enclosing submenu or menubar.
|
|
911
|
+
if (!submenu.open) return
|
|
912
|
+
event.preventDefault()
|
|
913
|
+
close(event)
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
for (const args of this) {
|
|
918
|
+
menu = MenuContext()
|
|
919
|
+
onOpenChange = args.onOpenChange
|
|
920
|
+
const parentOpen = parent?.open() ?? menu?.open ?? true
|
|
921
|
+
if (!parentOpen) {
|
|
922
|
+
children.close()
|
|
923
|
+
submenu.init(false)
|
|
924
|
+
}
|
|
925
|
+
const wasOpen = submenu.open
|
|
926
|
+
const opened = submenu.sync(parentOpen ? (args.open != null ? Boolean(args.open) : null) : false)
|
|
927
|
+
if (!wasOpen && opened) focusRestore++
|
|
928
|
+
if (wasOpen && !opened) {
|
|
929
|
+
geometryReady = false
|
|
930
|
+
pendingFocus = false
|
|
931
|
+
children.close()
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
SubContext({
|
|
935
|
+
adoptTriggerId: submenu.adoptTriggerId,
|
|
936
|
+
branch,
|
|
937
|
+
close,
|
|
938
|
+
contentId: submenu.contentId,
|
|
939
|
+
contentStyle: submenu.contentStyle,
|
|
940
|
+
open: opened,
|
|
941
|
+
setContent: submenu.setContent,
|
|
942
|
+
setOpen,
|
|
943
|
+
setTrigger: submenu.setTrigger,
|
|
944
|
+
get triggerId() { return submenu.triggerId },
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
LevelContext({ cluster: children, content: () => submenu.content, open: () => submenu.open })
|
|
948
|
+
yield <>{args.children}</>
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
/** Root provider for a nested menu. */
|
|
954
|
+
const MenuSub: Stateless<MenuSubArgs> = ({
|
|
955
|
+
children,
|
|
956
|
+
'data-slot': slot = 'menu-sub',
|
|
957
|
+
defaultOpen,
|
|
958
|
+
onOpenChange,
|
|
959
|
+
open,
|
|
960
|
+
...attrs
|
|
961
|
+
}) => (
|
|
962
|
+
<MenuSubRoot
|
|
963
|
+
{...rootAttrs(attrs)}
|
|
964
|
+
defaultOpen={defaultOpen}
|
|
965
|
+
onOpenChange={onOpenChange}
|
|
966
|
+
open={open}
|
|
967
|
+
attr:data-slot={slot}
|
|
968
|
+
>
|
|
969
|
+
{children}
|
|
970
|
+
</MenuSubRoot>
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
/** Trigger item that opens a nested menu. */
|
|
974
|
+
const MenuSubTrigger: Stateless<MenuSubTriggerArgs> = ({
|
|
975
|
+
children,
|
|
976
|
+
class: classes,
|
|
977
|
+
'data-slot': slot = 'menu-sub-trigger',
|
|
978
|
+
disabled,
|
|
979
|
+
iconClass,
|
|
980
|
+
id,
|
|
981
|
+
inset,
|
|
982
|
+
ref,
|
|
983
|
+
textValue,
|
|
984
|
+
'set:onclick': onClick,
|
|
985
|
+
'set:onmouseenter': onMouseEnter,
|
|
986
|
+
'set:onpointerenter': onPointerEnter,
|
|
987
|
+
'set:onpointermove': onPointerMove,
|
|
988
|
+
...attrs
|
|
989
|
+
}) => {
|
|
990
|
+
const menu = MenuContext()
|
|
991
|
+
const parent = LevelContext()
|
|
992
|
+
const sub = SubContext()
|
|
993
|
+
const disabledFlag = Boolean(disabled ?? menu?.disabled)
|
|
994
|
+
const label = textValue ?? text(children)
|
|
995
|
+
const highlight = pointerHighlight(disabledFlag, parent?.cluster.close, sub?.branch)
|
|
996
|
+
const adoptedId = sub?.adoptTriggerId(id)
|
|
997
|
+
|
|
998
|
+
return (
|
|
999
|
+
<div
|
|
1000
|
+
{...attrs}
|
|
1001
|
+
{...menuItems.attrs({ disabled: disabledFlag, label })}
|
|
1002
|
+
{...triggerAttrs({
|
|
1003
|
+
controls: sub?.contentId,
|
|
1004
|
+
expanded: Boolean(sub?.open),
|
|
1005
|
+
haspopup: 'menu',
|
|
1006
|
+
id: adoptedId ?? id,
|
|
1007
|
+
open: Boolean(sub?.open),
|
|
1008
|
+
ref,
|
|
1009
|
+
setTrigger: sub?.setTrigger,
|
|
1010
|
+
triggerId: sub?.triggerId,
|
|
1011
|
+
})}
|
|
1012
|
+
aria-disabled={flag(disabledFlag)}
|
|
1013
|
+
class={classes}
|
|
1014
|
+
data-inset={flag(inset)}
|
|
1015
|
+
data-menu-sub-trigger="true"
|
|
1016
|
+
data-slot={slot}
|
|
1017
|
+
role="menuitem"
|
|
1018
|
+
set:onclick={(event: Event) => {
|
|
1019
|
+
callHandler(onClick, event)
|
|
1020
|
+
if (event.defaultPrevented) return
|
|
1021
|
+
if (disabledFlag) return
|
|
1022
|
+
sub?.setOpen(!sub.open, event, true)
|
|
1023
|
+
}}
|
|
1024
|
+
set:onpointerenter={(event: Event) => {
|
|
1025
|
+
callHandler(onPointerEnter, event)
|
|
1026
|
+
if (!event.defaultPrevented) highlight(event)
|
|
1027
|
+
}}
|
|
1028
|
+
set:onpointermove={(event: Event) => {
|
|
1029
|
+
callHandler(onPointerMove, event)
|
|
1030
|
+
if (!event.defaultPrevented) highlight(event)
|
|
1031
|
+
}}
|
|
1032
|
+
set:onmouseenter={(event: Event) => {
|
|
1033
|
+
callHandler(onMouseEnter, event)
|
|
1034
|
+
if (event.defaultPrevented) return
|
|
1035
|
+
if (disabledFlag) return
|
|
1036
|
+
sub?.setOpen(true, event)
|
|
1037
|
+
}}
|
|
1038
|
+
tabindex="-1"
|
|
1039
|
+
>
|
|
1040
|
+
{children}
|
|
1041
|
+
<span aria-hidden="true" class={iconClass} />
|
|
1042
|
+
</div>
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Content for a nested menu. */
|
|
1047
|
+
const MenuSubContent: Stateless<MenuSubContentArgs> = ({
|
|
1048
|
+
children,
|
|
1049
|
+
class: classes,
|
|
1050
|
+
'data-slot': slot = 'menu-sub-content',
|
|
1051
|
+
ref,
|
|
1052
|
+
style,
|
|
1053
|
+
...attrs
|
|
1054
|
+
}) => {
|
|
1055
|
+
const sub = SubContext()
|
|
1056
|
+
|
|
1057
|
+
return (
|
|
1058
|
+
<div
|
|
1059
|
+
{...attrs}
|
|
1060
|
+
{...contentAttrs({
|
|
1061
|
+
id: sub?.contentId,
|
|
1062
|
+
open: Boolean(sub?.open),
|
|
1063
|
+
ref,
|
|
1064
|
+
setContent: sub?.setContent,
|
|
1065
|
+
style: sub?.contentStyle(style) ?? popupStyle(style),
|
|
1066
|
+
tabindex: '-1',
|
|
1067
|
+
})}
|
|
1068
|
+
aria-labelledby={sub?.triggerId}
|
|
1069
|
+
class={classes}
|
|
1070
|
+
data-menu-sub-content="true"
|
|
1071
|
+
data-slot={slot}
|
|
1072
|
+
hidden={undefined}
|
|
1073
|
+
role="menu"
|
|
1074
|
+
>
|
|
1075
|
+
{children}
|
|
1076
|
+
</div>
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export {
|
|
1081
|
+
Menu,
|
|
1082
|
+
MenuCheckboxItem,
|
|
1083
|
+
MenuContent,
|
|
1084
|
+
MenuGroup,
|
|
1085
|
+
MenuItem,
|
|
1086
|
+
MenuLabel,
|
|
1087
|
+
MenuRadioGroup,
|
|
1088
|
+
MenuRadioItem,
|
|
1089
|
+
MenuSeparator,
|
|
1090
|
+
MenuShortcut,
|
|
1091
|
+
MenuSub,
|
|
1092
|
+
MenuSubContent,
|
|
1093
|
+
MenuSubTrigger,
|
|
1094
|
+
MenuTrigger,
|
|
1095
|
+
}
|