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
@@ -0,0 +1,142 @@
1
+ import type { IntrinsicElements, Stateless, WithChildren } from 'ajo'
2
+ import { callHandler } from 'ajo-cloves'
3
+
4
+ /** Logical edge occupied by an InputGroup addon. */
5
+ export type InputGroupAddonAlign =
6
+ | 'block-end'
7
+ | 'block-start'
8
+ | 'inline-end'
9
+ | 'inline-start'
10
+
11
+ /** Arguments for a host that groups one control with addons and actions. */
12
+ export type InputGroupArgs = WithChildren<IntrinsicElements['div'] & {
13
+ /** Mark all grouped controls as disabled. */
14
+ disabled?: boolean
15
+ }>
16
+
17
+ /** Arguments for content placed along an InputGroup edge. */
18
+ export type InputGroupAddonArgs = WithChildren<IntrinsicElements['div'] & {
19
+ /** Placement relative to the input control. */
20
+ align?: InputGroupAddonAlign
21
+ }>
22
+
23
+ /** Arguments for a button rendered inside an InputGroup. */
24
+ export type InputGroupButtonArgs = WithChildren<IntrinsicElements['button'] & {
25
+ 'data-size'?: string
26
+ }>
27
+
28
+ /** Arguments for non-interactive text rendered inside an InputGroup. */
29
+ export type InputGroupTextArgs = WithChildren<IntrinsicElements['span']>
30
+
31
+ /** Arguments for the native input control owned by an InputGroup. */
32
+ export type InputGroupInputArgs = IntrinsicElements['input'] & {
33
+ 'data-slot'?: unknown
34
+ }
35
+
36
+ /** Arguments for the native textarea control owned by an InputGroup. */
37
+ export type InputGroupTextareaArgs = WithChildren<IntrinsicElements['textarea'] & {
38
+ 'data-slot'?: unknown
39
+ }>
40
+
41
+ /** Unstyled host that groups a control with addons and actions. */
42
+ const InputGroup: Stateless<InputGroupArgs> = ({
43
+ children,
44
+ disabled,
45
+ role = 'group',
46
+ ...attrs
47
+ }) => (
48
+ <div
49
+ {...attrs}
50
+ data-disabled={disabled ? 'true' : undefined}
51
+ data-slot="input-group"
52
+ role={role}
53
+ >
54
+ {children}
55
+ </div>
56
+ )
57
+
58
+ /** Unstyled addon that forwards non-button clicks to the grouped control. */
59
+ const InputGroupAddon: Stateless<InputGroupAddonArgs> = ({
60
+ align = 'inline-start',
61
+ children,
62
+ role = 'group',
63
+ 'set:onclick': onclick,
64
+ ...attrs
65
+ }) => (
66
+ <div
67
+ {...attrs}
68
+ data-align={align}
69
+ data-slot="input-group-addon"
70
+ role={role}
71
+ set:onclick={(event: MouseEvent) => {
72
+ callHandler(onclick, event)
73
+ const target = event.target as HTMLElement
74
+ if (target.closest('button')) return
75
+ // The control slot wins over document order: a hidden form input
76
+ // rendered before it must never swallow the forwarded focus.
77
+ const parent = (event.currentTarget as HTMLElement).parentElement
78
+ ;(parent?.querySelector<HTMLElement>('[data-slot="input-group-control"]')
79
+ ?? parent?.querySelector<HTMLElement>('input,textarea'))
80
+ ?.focus()
81
+ }}
82
+ >
83
+ {children}
84
+ </div>
85
+ )
86
+
87
+ /** Unstyled button rendered inside an InputGroup. */
88
+ const InputGroupButton: Stateless<InputGroupButtonArgs> = ({
89
+ children,
90
+ 'data-slot': slot = 'input-group-button',
91
+ type = 'button',
92
+ ...attrs
93
+ }) => (
94
+ <button
95
+ {...attrs}
96
+ data-slot={slot}
97
+ type={type}
98
+ >
99
+ {children}
100
+ </button>
101
+ )
102
+
103
+ /** Unstyled supporting text rendered inside an InputGroup. */
104
+ const InputGroupText: Stateless<InputGroupTextArgs> = ({ children, ...attrs }) => (
105
+ <span {...attrs} data-slot="input-group-text">{children}</span>
106
+ )
107
+
108
+ /** Unstyled native input carrying the InputGroup control slot. */
109
+ const InputGroupInput: Stateless<InputGroupInputArgs> = ({
110
+ 'data-slot': slot = 'input-group-control',
111
+ type = 'text',
112
+ ...attrs
113
+ }) => (
114
+ <input
115
+ {...attrs}
116
+ data-slot={slot}
117
+ type={type}
118
+ />
119
+ )
120
+
121
+ /** Unstyled native textarea carrying the InputGroup control slot. */
122
+ const InputGroupTextarea: Stateless<InputGroupTextareaArgs> = ({
123
+ children,
124
+ 'data-slot': slot = 'input-group-control',
125
+ ...attrs
126
+ }) => (
127
+ <textarea
128
+ {...attrs}
129
+ data-slot={slot}
130
+ >
131
+ {children}
132
+ </textarea>
133
+ )
134
+
135
+ export {
136
+ InputGroup,
137
+ InputGroupAddon,
138
+ InputGroupButton,
139
+ InputGroupInput,
140
+ InputGroupText,
141
+ InputGroupTextarea,
142
+ }
@@ -0,0 +1,324 @@
1
+ import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
2
+ import { callHandler, callRef, clamp, controlled, dom } from 'ajo-cloves'
3
+ import { context } from 'ajo/context'
4
+ import type { FixedArgs, OmitArg } from './utils'
5
+
6
+ /** Built-in InputOTP pattern accepting one or more ASCII digits. */
7
+ export const REGEXP_ONLY_DIGITS = /^[0-9]+$/
8
+ /** Built-in InputOTP pattern accepting one or more ASCII letters or digits. */
9
+ export const REGEXP_ONLY_DIGITS_AND_CHARS = /^[a-zA-Z0-9]+$/
10
+
11
+ /** Arguments for a controlled or uncontrolled one-time-password input. */
12
+ export type InputOTPArgs = WithChildren<OmitArg<IntrinsicElements['input'], 'children' | 'defaultValue' | 'maxLength' | 'onChange' | 'pattern' | 'value'> & {
13
+ /** Initial uncontrolled value. */
14
+ defaultValue?: string
15
+ /** Classes for the hidden native input. */
16
+ inputClass?: string
17
+ /** Maximum accepted characters. */
18
+ maxLength?: number
19
+ /** Called when the OTP value changes. */
20
+ onValueChange?: (value: string, event?: Event) => void
21
+ /** Called when the OTP reaches `maxLength`. */
22
+ onComplete?: (value: string, event?: Event) => void
23
+ /** Accepted character pattern. */
24
+ pattern?: RegExp | string
25
+ /** Controlled value. */
26
+ value?: string
27
+ }> & FixedArgs<'onChange'>
28
+
29
+ /** Arguments for a visual group of adjacent OTP slots. */
30
+ export type InputOTPGroupArgs = WithChildren<IntrinsicElements['div']>
31
+
32
+ /** Arguments for one indexed visual slot in an InputOTP value. */
33
+ export type InputOTPSlotArgs = WithChildren<IntrinsicElements['div'] & {
34
+ /** Classes for the caret wrapper element. */
35
+ caretClass?: string
36
+ /** Classes for the caret mark element. */
37
+ caretMarkClass?: string
38
+ /** Slot index in the OTP value. */
39
+ index: number
40
+ }>
41
+
42
+ /** Arguments for a separator between visual OTP slot groups. */
43
+ export type InputOTPSeparatorArgs = WithChildren<IntrinsicElements['div']>
44
+
45
+ type InputOTPContextValue = {
46
+ active: number
47
+ disabled: boolean
48
+ focus: (index: number) => void
49
+ maxLength: number
50
+ value: string
51
+ }
52
+
53
+ const InputOTPContext = context<InputOTPContextValue | null>(null)
54
+
55
+ const isRegExp = (value: unknown): value is RegExp =>
56
+ Object.prototype.toString.call(value) === '[object RegExp]'
57
+
58
+ const accepts = (pattern: RegExp | string | undefined, char: string) => {
59
+ if (!pattern) return true
60
+ if (isRegExp(pattern)) {
61
+ pattern.lastIndex = 0
62
+ return pattern.test(char)
63
+ }
64
+ return new RegExp(pattern).test(char)
65
+ }
66
+
67
+ const clean = (value: unknown, maxLength: number, pattern?: RegExp | string) =>
68
+ String(value ?? '')
69
+ .split('')
70
+ .filter(char => accepts(pattern, char))
71
+ .slice(0, Math.max(1, maxLength))
72
+ .join('')
73
+
74
+ const inputPattern = (pattern: RegExp | string | undefined) =>
75
+ isRegExp(pattern) ? pattern.source : pattern
76
+
77
+ const nextSelection = (input: HTMLInputElement | null, fallback: number) =>
78
+ Math.min(input?.selectionStart ?? fallback, Math.max(0, input?.value.length ?? fallback))
79
+
80
+ const completionState = (value: string, maxLength: number) =>
81
+ value.length === maxLength ? 'complete' : 'incomplete'
82
+
83
+ // The runtime routes `ref` on a stateful component to its host, so the
84
+ // wrapper forwards the consumer's input ref under a plain args key.
85
+ type InputOTPRootArgs = OmitArg<InputOTPArgs, 'ref'> & {
86
+ inputRef?: InputOTPArgs['ref']
87
+ } & FixedArgs<'ref'>
88
+
89
+ const InputOTPRoot: Stateful<InputOTPRootArgs> = function* ({
90
+ defaultValue = '',
91
+ maxLength = 6,
92
+ pattern,
93
+ value,
94
+ }) {
95
+ let input: HTMLInputElement | null = null
96
+ const initial = clean(value ?? defaultValue, maxLength, pattern)
97
+ let active = Math.min(initial.length, Math.max(0, maxLength - 1))
98
+ let onValueChange: InputOTPArgs['onValueChange']
99
+ let onComplete: InputOTPArgs['onComplete']
100
+ let previousComplete = initial.length === maxLength ? initial : ''
101
+
102
+ const emit = (next: string, event?: Event) => {
103
+ onValueChange?.(next, event)
104
+ if (next.length === maxLength && next !== previousComplete) {
105
+ previousComplete = next
106
+ onComplete?.(next, event)
107
+ } else if (next.length !== maxLength) {
108
+ previousComplete = ''
109
+ }
110
+ }
111
+ const state = controlled<string>(this, {
112
+ fallback: initial,
113
+ onChange: (next, event) => emit(next, event),
114
+ })
115
+
116
+ const setValue = (
117
+ next: string,
118
+ args: InputOTPRootArgs,
119
+ nextActive = next.length,
120
+ event?: Event,
121
+ ) => {
122
+ const length = args.maxLength ?? maxLength
123
+ const parsed = clean(next, length, args.pattern)
124
+ active = Math.min(nextActive, Math.max(0, length - 1))
125
+ if (input) input.value = parsed
126
+ state.accept(parsed, event)
127
+ }
128
+
129
+ const focus = (index: number) => {
130
+ active = clamp(index, 0, Math.max(0, maxLength - 1))
131
+ input?.focus()
132
+ input?.setSelectionRange(active, active)
133
+ this.next()
134
+ }
135
+
136
+ for (const args of this) {
137
+ const {
138
+ children,
139
+ class: _class,
140
+ defaultValue: _defaultValue,
141
+ inputClass,
142
+ inputRef,
143
+ maxLength: _maxLength,
144
+ onValueChange: change,
145
+ onComplete: complete,
146
+ pattern,
147
+ value,
148
+ 'set:onblur': onBlur,
149
+ 'set:onfocus': onFocus,
150
+ 'set:oninput': onInput,
151
+ 'set:onkeyup': onKeyUp,
152
+ 'set:onpaste': onPaste,
153
+ ...inputAttrs
154
+ } = args
155
+
156
+ maxLength = _maxLength ?? 6
157
+ onValueChange = change
158
+ onComplete = complete
159
+ state.sync(value != null ? clean(value, maxLength, pattern) : undefined)
160
+ if (!state.controlled) {
161
+ const parsed = clean(state.value, maxLength, pattern)
162
+ if (parsed !== state.value) state.init(parsed)
163
+ }
164
+ active = Math.min(active, Math.max(0, maxLength - 1))
165
+ if (dom(this)) this.dataset.state = completionState(state.value, maxLength)
166
+
167
+ InputOTPContext({
168
+ active,
169
+ disabled: Boolean(inputAttrs.disabled),
170
+ focus,
171
+ maxLength,
172
+ value: state.value,
173
+ })
174
+
175
+ yield (
176
+ <>
177
+ <input
178
+ {...inputAttrs}
179
+ aria-label={inputAttrs['aria-label'] ?? 'One-time password'}
180
+ autocomplete={inputAttrs.autocomplete ?? 'one-time-code'}
181
+ class={inputClass}
182
+ data-slot="input-otp-input"
183
+ data-state={completionState(state.value, maxLength)}
184
+ inputmode={inputAttrs.inputmode ?? 'numeric'}
185
+ maxLength={maxLength}
186
+ pattern={inputPattern(pattern)}
187
+ ref={element => {
188
+ input = element
189
+ callRef(inputRef, element)
190
+ }}
191
+ type={inputAttrs.type ?? 'text'}
192
+ set:onblur={(event: FocusEvent) => {
193
+ callHandler(onBlur, event)
194
+ this.next(() => active = -1)
195
+ }}
196
+ set:onfocus={(event: FocusEvent) => {
197
+ callHandler(onFocus, event)
198
+ this.next(() => active = Math.min(state.value.length, Math.max(0, maxLength - 1)))
199
+ }}
200
+ set:oninput={(event: Event) => {
201
+ callHandler(onInput, event)
202
+ const control = event.currentTarget as HTMLInputElement
203
+ setValue(control.value, args, nextSelection(control, control.value.length), event)
204
+ }}
205
+ set:onkeyup={(event: KeyboardEvent) => {
206
+ callHandler(onKeyUp, event)
207
+ this.next(() => {
208
+ active = nextSelection(event.currentTarget as HTMLInputElement, active)
209
+ })
210
+ }}
211
+ set:onpaste={(event: ClipboardEvent) => {
212
+ callHandler(onPaste, event)
213
+ if (event.defaultPrevented) return
214
+ const pasted = event.clipboardData?.getData('text') ?? ''
215
+ if (!pasted) return
216
+ event.preventDefault()
217
+ setValue(pasted, args, pasted.length, event)
218
+ }}
219
+ set:value={state.value}
220
+ />
221
+ {children}
222
+ </>
223
+ )
224
+ }
225
+ }
226
+
227
+
228
+ /** Unstyled one-time password input with visible slot context. */
229
+ const InputOTP: Stateless<InputOTPArgs> = ({
230
+ children,
231
+ class: classes,
232
+ disabled,
233
+ ref,
234
+ ...attrs
235
+ }) => {
236
+ const maxLength = attrs.maxLength ?? 6
237
+ const state = completionState(clean(attrs.value ?? attrs.defaultValue, maxLength, attrs.pattern), maxLength)
238
+
239
+ return (
240
+ <InputOTPRoot
241
+ {...attrs}
242
+ disabled={disabled}
243
+ inputRef={ref}
244
+ attr:class={classes}
245
+ attr:data-disabled={disabled ? 'true' : undefined}
246
+ attr:data-slot="input-otp"
247
+ attr:data-state={state}
248
+ >
249
+ {children}
250
+ </InputOTPRoot>
251
+ )
252
+ }
253
+
254
+ /** Unstyled visual group for adjacent OTP slots. */
255
+ const InputOTPGroup: Stateless<InputOTPGroupArgs> = ({
256
+ children,
257
+ 'data-slot': slot = 'input-otp-group',
258
+ ...attrs
259
+ }) => (
260
+ <div {...attrs} data-slot={slot}>
261
+ {children}
262
+ </div>
263
+ )
264
+
265
+ /** Unstyled OTP slot synchronized with the nearest OTP input. */
266
+ const InputOTPSlot: Stateless<InputOTPSlotArgs> = ({
267
+ caretClass,
268
+ caretMarkClass,
269
+ children,
270
+ index,
271
+ role = 'presentation',
272
+ 'set:onclick': onClick,
273
+ ...attrs
274
+ }) => {
275
+ const context = InputOTPContext()
276
+ if (!context) throw new Error('InputOTPSlot must be used within an <InputOTP />')
277
+
278
+ const char = context.value[index]
279
+ const active = context.active === index
280
+ const caret = active && !char && !context.disabled
281
+ const state = active ? 'active' : 'inactive'
282
+
283
+ return (
284
+ <div
285
+ {...attrs}
286
+ aria-hidden="true"
287
+ data-active={active ? 'true' : 'false'}
288
+ data-disabled={context.disabled ? 'true' : undefined}
289
+ data-slot="input-otp-slot"
290
+ data-state={state}
291
+ role={role}
292
+ set:onclick={(event: MouseEvent) => {
293
+ callHandler(onClick, event)
294
+ if (!event.defaultPrevented && !context.disabled) context.focus(index)
295
+ }}
296
+ >
297
+ {children ?? char}
298
+ {caret && (
299
+ <div aria-hidden="true" class={caretClass} data-slot="input-otp-caret">
300
+ <div class={caretMarkClass} data-slot="input-otp-caret-mark" />
301
+ </div>
302
+ )}
303
+ </div>
304
+ )
305
+ }
306
+
307
+ /** Unstyled separator between OTP slot groups. */
308
+ const InputOTPSeparator: Stateless<InputOTPSeparatorArgs> = ({
309
+ children,
310
+ role = 'separator',
311
+ 'data-slot': slot = 'input-otp-separator',
312
+ ...attrs
313
+ }) => (
314
+ <div {...attrs} data-slot={slot} role={role}>
315
+ {children}
316
+ </div>
317
+ )
318
+
319
+ export {
320
+ InputOTP,
321
+ InputOTPGroup,
322
+ InputOTPSeparator,
323
+ InputOTPSlot,
324
+ }
@@ -0,0 +1,124 @@
1
+ import { context } from 'ajo/context'
2
+ import { collection } from './collection'
3
+ import type { PositionReference } from './position'
4
+
5
+ export type MenuInvocationFocus = 'content' | 'first'
6
+
7
+ type InvokeReference = (
8
+ reference: PositionReference,
9
+ source: HTMLElement,
10
+ event: Event,
11
+ focus: MenuInvocationFocus,
12
+ ) => void
13
+
14
+ /** Private ContextMenu adapter that owns virtual-reference identity. */
15
+ export type ContextMenuInvoke = (
16
+ invoke: InvokeReference,
17
+ x: number,
18
+ y: number,
19
+ event: Event,
20
+ source: HTMLElement,
21
+ focus: MenuInvocationFocus,
22
+ ) => void
23
+
24
+ type ContextMenuComposition = {
25
+ invoke: ContextMenuInvoke
26
+ profile: 'context'
27
+ restoreFocus: () => void
28
+ }
29
+
30
+ type MenubarComposition = {
31
+ ackFocus: () => boolean
32
+ profile: 'menubar'
33
+ }
34
+
35
+ type MenuComposition = ContextMenuComposition | MenubarComposition
36
+
37
+ type MenuInvocation = {
38
+ adoptTriggerId: (id?: unknown) => string
39
+ contentId: string
40
+ disabled: boolean
41
+ invoke: (x: number, y: number, event: Event, source: HTMLElement, focus: MenuInvocationFocus) => void
42
+ open: boolean
43
+ }
44
+
45
+ const CompositionContext = context<MenuComposition | null>(null)
46
+ const InvocationContext = context<MenuInvocation | null>(null)
47
+
48
+ /** Selects ContextMenu policy for exactly one descendant Menu root. */
49
+ export const provideContextMenuComposition = (invoke: ContextMenuInvoke, restoreFocus: () => void) =>
50
+ CompositionContext({ invoke, profile: 'context', restoreFocus })
51
+
52
+ /** Selects Menubar policy and its commit-time focus acknowledgement. */
53
+ export const provideMenubarComposition = (ackFocus: () => boolean) =>
54
+ CompositionContext({ ackFocus, profile: 'menubar' })
55
+
56
+ /** Returns the private family policy selected by a composing Menu root. */
57
+ export const menuComposition = () => CompositionContext()
58
+
59
+ /** Prevents a composing family policy from leaking into nested Menu roots. */
60
+ export const isolateMenuComposition = () => CompositionContext(null)
61
+
62
+ /** Publishes Menu's private context-invocation controller to its trigger. */
63
+ export const provideMenuInvocation = (value: MenuInvocation) => InvocationContext(value)
64
+
65
+ /** Prevents a context invocation controller from leaking through a nested Menu. */
66
+ export const isolateMenuInvocation = () => InvocationContext(null)
67
+
68
+ /** Returns the nearest private context-invocation controller. */
69
+ export const menuInvocation = () => InvocationContext()
70
+
71
+ /** Collection registry for menu items; disabled items are skipped in the focus order. */
72
+ export const menuItems = collection('menu')
73
+
74
+ /** Matches root and submenu content surfaces. */
75
+ export const SURFACE_SELECTOR = '[data-menu-sub-content="true"],[data-menu-content="true"]'
76
+
77
+ /** Items owned directly by one menu surface, excluding nested submenu items. */
78
+ export const surfaceItems = (surface: HTMLElement | null) =>
79
+ menuItems.items(surface).filter(item => item.closest(SURFACE_SELECTOR) === surface)
80
+
81
+ /** Focuses the first or last direct item of one menu surface. */
82
+ export const focusEdge = (surface: HTMLElement | null, which: 'first' | 'last') => {
83
+ const list = surfaceItems(surface)
84
+ menuItems.focusItem(surface, which === 'first' ? list[0] : list[list.length - 1])
85
+ }
86
+
87
+ /** One direct submenu branch owned by a menu level. */
88
+ export type MenuBranch = {
89
+ close: (event?: Event) => void
90
+ content: () => HTMLElement | null
91
+ prune: (target: Node, event: Event) => void
92
+ trigger: () => HTMLElement | null
93
+ }
94
+
95
+ /** Private tree seam: each menu level owns only its direct submenu branches. */
96
+ export const cluster = () => {
97
+ const branches = new Set<MenuBranch>()
98
+
99
+ const contains = (branch: MenuBranch, target: Node) =>
100
+ Boolean(branch.trigger()?.contains(target) || branch.content()?.contains(target))
101
+
102
+ return {
103
+ /** Registers one direct branch for exactly its mounted lifetime. */
104
+ register(branch: MenuBranch) {
105
+ branches.add(branch)
106
+ return () => branches.delete(branch)
107
+ },
108
+ /** Closes every direct branch except an optional active sibling. */
109
+ close(event?: Event, except?: MenuBranch) {
110
+ for (const branch of [...branches]) {
111
+ if (branch !== except) branch.close(event)
112
+ }
113
+ },
114
+ /** Retains the branch containing target, closes siblings, and recurses. */
115
+ prune(target: Node, event: Event) {
116
+ for (const branch of [...branches]) {
117
+ if (contains(branch, target)) branch.prune(target, event)
118
+ else branch.close(event)
119
+ }
120
+ },
121
+ }
122
+ }
123
+
124
+ export type MenuCluster = ReturnType<typeof cluster>