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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. 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
+ }