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/native.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** HTMLElement shape augmented by the native Popover API. */
|
|
2
|
+
export type PopoverElement = HTMLElement & {
|
|
3
|
+
hidePopover?: () => void
|
|
4
|
+
showPopover?: (options?: { source?: HTMLElement }) => void
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Checks whether a native popover is currently open. */
|
|
8
|
+
export const popoverOpen = (element: HTMLElement) =>
|
|
9
|
+
typeof element.matches === 'function' && element.matches(':popover-open')
|
|
10
|
+
|
|
11
|
+
/** Opens a native popover and reports whether it is open. */
|
|
12
|
+
export const openPopover = (element: PopoverElement, source?: HTMLElement | null) => {
|
|
13
|
+
if (popoverOpen(element)) return true
|
|
14
|
+
if (typeof element.showPopover !== 'function') return false
|
|
15
|
+
if (source) element.showPopover({ source })
|
|
16
|
+
else element.showPopover()
|
|
17
|
+
return popoverOpen(element)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Closes a native popover and reports whether it is closed. */
|
|
21
|
+
export const closePopover = (element: PopoverElement) => {
|
|
22
|
+
if (!popoverOpen(element)) return true
|
|
23
|
+
if (typeof element.hidePopover !== 'function') return false
|
|
24
|
+
element.hidePopover()
|
|
25
|
+
return !popoverOpen(element)
|
|
26
|
+
}
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler, id, listen, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import { bar } from './bar'
|
|
5
|
+
import { contentAttrs, popup, type PopupView } from './popup'
|
|
6
|
+
import type { ReservedPositionArg } from './position'
|
|
7
|
+
import type { FixedArgs, OmitArg, PopupPosition } from './utils'
|
|
8
|
+
import { text, triggerAttrs } from './utils'
|
|
9
|
+
export type { PopupPlacement, PopupPosition } from './utils'
|
|
10
|
+
|
|
11
|
+
/** Stable identifier for an open navigation-menu item. */
|
|
12
|
+
export type NavigationMenuValue = string
|
|
13
|
+
|
|
14
|
+
/** Props for the navigation-menu root and its controlled state. */
|
|
15
|
+
export type NavigationMenuArgs = WithChildren<OmitArg<IntrinsicElements['nav'], 'onchange' | ReservedPositionArg> & PopupPosition & {
|
|
16
|
+
/** Controlled open item value. Empty string closes every content panel. */
|
|
17
|
+
value?: NavigationMenuValue
|
|
18
|
+
/** Initial open item value for uncontrolled usage. */
|
|
19
|
+
defaultValue?: NavigationMenuValue
|
|
20
|
+
/** Hover-intent delay before a panel opens, in milliseconds. */
|
|
21
|
+
openDelay?: number
|
|
22
|
+
/** Hover-intent delay before a panel closes after the pointer leaves, in milliseconds. */
|
|
23
|
+
closeDelay?: number
|
|
24
|
+
/** Called whenever the open item value changes. */
|
|
25
|
+
onValueChange?: (value: NavigationMenuValue, event?: Event) => void
|
|
26
|
+
/** Additional CSS classes. */
|
|
27
|
+
class?: string
|
|
28
|
+
}> & FixedArgs<'onchange' | ReservedPositionArg>
|
|
29
|
+
|
|
30
|
+
/** Props for the list containing navigation-menu items. */
|
|
31
|
+
export type NavigationMenuListArgs = WithChildren<IntrinsicElements['ul'] & {
|
|
32
|
+
/** Additional CSS classes. */
|
|
33
|
+
class?: string
|
|
34
|
+
}>
|
|
35
|
+
|
|
36
|
+
/** Props for a navigation-menu item and its stable value. */
|
|
37
|
+
export type NavigationMenuItemArgs = WithChildren<OmitArg<IntrinsicElements['li'], 'gap' | 'placement' | ReservedPositionArg> & {
|
|
38
|
+
/** Stable value used by controlled NavigationMenu state. */
|
|
39
|
+
value?: NavigationMenuValue
|
|
40
|
+
/** Disable this item and its trigger. */
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
/** Additional CSS classes. */
|
|
43
|
+
class?: string
|
|
44
|
+
}> & FixedArgs<'gap' | 'placement' | ReservedPositionArg>
|
|
45
|
+
|
|
46
|
+
/** Props for a button that opens a navigation-menu panel. */
|
|
47
|
+
export type NavigationMenuTriggerArgs = WithChildren<IntrinsicElements['button'] & {
|
|
48
|
+
/** Plain-text label used for keyboard typeahead. */
|
|
49
|
+
textValue?: string
|
|
50
|
+
/** Additional CSS classes. */
|
|
51
|
+
class?: string
|
|
52
|
+
}>
|
|
53
|
+
|
|
54
|
+
/** Props for a floating navigation-menu panel. */
|
|
55
|
+
export type NavigationMenuContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'aria-labelledby' | 'hidden' | 'id' | 'popover' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
|
|
56
|
+
/** Additional CSS classes. */
|
|
57
|
+
class?: string
|
|
58
|
+
/** Inline CSS declarations composed with live positioning styles. */
|
|
59
|
+
style?: string
|
|
60
|
+
}> & FixedArgs<'aria-labelledby' | 'gap' | 'hidden' | 'id' | 'placement' | 'popover' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
|
|
61
|
+
|
|
62
|
+
/** Props for an anchor or button rendered inside navigation-menu content. */
|
|
63
|
+
export type NavigationMenuLinkArgs = WithChildren<(IntrinsicElements['a'] & IntrinsicElements['button']) & {
|
|
64
|
+
/** Render as a native anchor or button. */
|
|
65
|
+
as?: 'a' | 'button'
|
|
66
|
+
/** Mark the link as active. */
|
|
67
|
+
active?: boolean
|
|
68
|
+
/** Additional CSS classes. */
|
|
69
|
+
class?: string
|
|
70
|
+
}>
|
|
71
|
+
|
|
72
|
+
type RootContextValue = {
|
|
73
|
+
close: (event?: Event) => void
|
|
74
|
+
closeDelay: number
|
|
75
|
+
follow: (value: string, event?: Event) => void
|
|
76
|
+
gap: PopupPosition['gap']
|
|
77
|
+
open: (value: string, event?: Event) => void
|
|
78
|
+
openDelay: number
|
|
79
|
+
placement: PopupPosition['placement']
|
|
80
|
+
/** One-shot: true when a keyboard open requested focus into this value's panel. */
|
|
81
|
+
takeFocus: (value: string) => boolean
|
|
82
|
+
value: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ItemContextValue = {
|
|
86
|
+
adoptTriggerId: PopupView['adoptTriggerId']
|
|
87
|
+
clickTrigger: (event: Event) => void
|
|
88
|
+
close: (event?: Event) => void
|
|
89
|
+
contentId: string
|
|
90
|
+
contentStyle: PopupView['contentStyle']
|
|
91
|
+
disabled: boolean
|
|
92
|
+
open: boolean
|
|
93
|
+
registerContentHover: (hovering: boolean, event: Event) => void
|
|
94
|
+
registerTriggerFocus: (event: FocusEvent) => void
|
|
95
|
+
registerTriggerHover: (hovering: boolean, event: Event) => void
|
|
96
|
+
setContent: (element: HTMLDivElement | null) => void
|
|
97
|
+
setTrigger: (element: HTMLButtonElement | null) => void
|
|
98
|
+
triggerId: string
|
|
99
|
+
value: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const RootContext = context<RootContextValue | null>(null)
|
|
103
|
+
const ItemContext = context<ItemContextValue | null>(null)
|
|
104
|
+
|
|
105
|
+
const triggers = (root: HTMLElement) =>
|
|
106
|
+
Array.from(root.querySelectorAll<HTMLButtonElement>('[data-navigation-menu-trigger="true"]'))
|
|
107
|
+
.filter(trigger =>
|
|
108
|
+
!trigger.disabled
|
|
109
|
+
&& trigger.offsetParent !== null
|
|
110
|
+
&& trigger.closest('[data-slot="navigation-menu"]') === root)
|
|
111
|
+
|
|
112
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
113
|
+
|
|
114
|
+
const NavigationMenuRoot: Stateful<NavigationMenuArgs, 'nav'> = function* ({ defaultValue, value }) {
|
|
115
|
+
let closeDelay = 300
|
|
116
|
+
const closeFocus = { current: null as HTMLElement | null }
|
|
117
|
+
let onValueChange: NavigationMenuArgs['onValueChange']
|
|
118
|
+
let openDelay = 200
|
|
119
|
+
let pendingFocus = ''
|
|
120
|
+
|
|
121
|
+
const state = bar(this, {
|
|
122
|
+
triggers: () => triggers(this),
|
|
123
|
+
initialValue: String(value ?? defaultValue ?? ''),
|
|
124
|
+
onValueChange: (next, event) => onValueChange?.(next, event),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const openTrigger = () =>
|
|
128
|
+
triggers(this).find(trigger => trigger.dataset.value === state.value)
|
|
129
|
+
|
|
130
|
+
const focusPanel = (trigger: HTMLElement) => {
|
|
131
|
+
const contentId = trigger.getAttribute('aria-controls')
|
|
132
|
+
const panel = contentId ? document.getElementById(contentId) : null
|
|
133
|
+
panel?.querySelector<HTMLElement>(FOCUSABLE)?.focus()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const close = (event?: Event, focus?: HTMLElement | null) => {
|
|
137
|
+
pendingFocus = ''
|
|
138
|
+
closeFocus.current = focus ?? null
|
|
139
|
+
state.close(event)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
listen(this, 'keydown', (event: KeyboardEvent) => {
|
|
143
|
+
if (event.defaultPrevented) return
|
|
144
|
+
const target = event.target as HTMLElement | null
|
|
145
|
+
if (target?.closest('[data-slot="navigation-menu"]') !== this) return
|
|
146
|
+
const trigger = target?.closest<HTMLButtonElement>('[data-navigation-menu-trigger="true"]')
|
|
147
|
+
|
|
148
|
+
if (!trigger) {
|
|
149
|
+
// Escape inside an open panel returns focus to its trigger and
|
|
150
|
+
// closes; while closed the key passes through untouched (a hosting
|
|
151
|
+
// dialog keeps its Escape).
|
|
152
|
+
if (event.key === 'Escape' && state.value) {
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
close(event, openTrigger())
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (state.handle(event)) return
|
|
160
|
+
|
|
161
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
const next = trigger.dataset.value ?? ''
|
|
164
|
+
if (state.value === next) {
|
|
165
|
+
// Already open: ArrowDown enters the panel, Enter/Space toggle closed.
|
|
166
|
+
if (event.key === 'ArrowDown') focusPanel(trigger)
|
|
167
|
+
else close(event)
|
|
168
|
+
} else {
|
|
169
|
+
pendingFocus = next
|
|
170
|
+
state.setValue(next, event)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Focus-out close: panel links stay tabbable, so Tab walks through them;
|
|
176
|
+
// tabbing past the last one (or focus leaving the nav and its panels
|
|
177
|
+
// entirely) closes the open panel. Focus landing on another trigger runs
|
|
178
|
+
// the follow policy instead of closing.
|
|
179
|
+
listen(this, 'focusout', (event: FocusEvent) => {
|
|
180
|
+
if (!state.value) return
|
|
181
|
+
const next = event.relatedTarget as Node | null
|
|
182
|
+
if (next && this.contains(next)) return
|
|
183
|
+
// Window blur (alt-tab) fires focusout with a null relatedTarget while
|
|
184
|
+
// the focused element stays inside the nav: not a focus departure.
|
|
185
|
+
if (!next && this.contains(document.activeElement)) return
|
|
186
|
+
close(event)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
for (const args of this) {
|
|
190
|
+
closeDelay = Math.max(0, Number(args.closeDelay ?? 300))
|
|
191
|
+
onValueChange = args.onValueChange
|
|
192
|
+
openDelay = Math.max(0, Number(args.openDelay ?? 200))
|
|
193
|
+
state.sync(args.value != null ? String(args.value ?? '') : undefined)
|
|
194
|
+
if (pendingFocus && pendingFocus !== state.value) pendingFocus = ''
|
|
195
|
+
if (closeFocus.current) {
|
|
196
|
+
const target = closeFocus.current
|
|
197
|
+
closeFocus.current = null
|
|
198
|
+
if (!state.value) queueMicrotask(() => {
|
|
199
|
+
if (!state.value) target.focus()
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
RootContext({
|
|
204
|
+
close,
|
|
205
|
+
closeDelay,
|
|
206
|
+
// Pointer-driven opens clear any stale keyboard focus request.
|
|
207
|
+
follow: (next, event) => {
|
|
208
|
+
pendingFocus = ''
|
|
209
|
+
state.follow(next, event)
|
|
210
|
+
},
|
|
211
|
+
gap: args.gap,
|
|
212
|
+
open: (next, event) => {
|
|
213
|
+
// An engine echo for the same keyboard-requested value must not
|
|
214
|
+
// invalidate its post-geometry focus token. Pointer opens have no
|
|
215
|
+
// matching token and clear any stale request.
|
|
216
|
+
if (pendingFocus !== next) pendingFocus = ''
|
|
217
|
+
state.setValue(next, event)
|
|
218
|
+
},
|
|
219
|
+
openDelay,
|
|
220
|
+
placement: args.placement,
|
|
221
|
+
takeFocus: value => {
|
|
222
|
+
if (!value || pendingFocus !== value) return false
|
|
223
|
+
pendingFocus = ''
|
|
224
|
+
return true
|
|
225
|
+
},
|
|
226
|
+
value: state.value,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
yield <>{args.children}</>
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
NavigationMenuRoot.is = 'nav'
|
|
234
|
+
|
|
235
|
+
/** Unstyled root landmark and state provider for a navigation menu. */
|
|
236
|
+
const NavigationMenu: Stateless<NavigationMenuArgs> = ({
|
|
237
|
+
children,
|
|
238
|
+
class: classes,
|
|
239
|
+
closeDelay,
|
|
240
|
+
defaultValue,
|
|
241
|
+
gap,
|
|
242
|
+
onValueChange,
|
|
243
|
+
openDelay,
|
|
244
|
+
placement,
|
|
245
|
+
value,
|
|
246
|
+
...attrs
|
|
247
|
+
}) => (
|
|
248
|
+
<NavigationMenuRoot
|
|
249
|
+
{...rootAttrs(attrs)}
|
|
250
|
+
closeDelay={closeDelay}
|
|
251
|
+
defaultValue={defaultValue}
|
|
252
|
+
gap={gap}
|
|
253
|
+
onValueChange={onValueChange}
|
|
254
|
+
openDelay={openDelay}
|
|
255
|
+
placement={placement}
|
|
256
|
+
value={value}
|
|
257
|
+
attr:class={classes}
|
|
258
|
+
attr:data-slot="navigation-menu"
|
|
259
|
+
>
|
|
260
|
+
{children}
|
|
261
|
+
</NavigationMenuRoot>
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
/** Unstyled horizontal list of navigation menu items. */
|
|
265
|
+
const NavigationMenuList: Stateless<NavigationMenuListArgs> = ({ children, class: classes, ...attrs }) => (
|
|
266
|
+
<ul {...attrs} class={classes} data-slot="navigation-menu-list">
|
|
267
|
+
{children}
|
|
268
|
+
</ul>
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const NavigationMenuItemRoot: Stateful<NavigationMenuItemArgs, 'li'> = function* ({ value }) {
|
|
272
|
+
const fallback = value ?? id('navigation-menu-item')
|
|
273
|
+
let disabled = false
|
|
274
|
+
let itemValue = String(fallback)
|
|
275
|
+
let root: RootContextValue | null = null
|
|
276
|
+
let item: PopupView<HTMLButtonElement, HTMLDivElement>
|
|
277
|
+
|
|
278
|
+
item = popup<HTMLButtonElement, HTMLDivElement>(this, {
|
|
279
|
+
prefix: 'navigation-menu',
|
|
280
|
+
profile: 'navigation',
|
|
281
|
+
initialOpen: false,
|
|
282
|
+
disabled: () => disabled,
|
|
283
|
+
hover: {
|
|
284
|
+
openDelay: () => root?.openDelay ?? 200,
|
|
285
|
+
closeDelay: () => root?.closeDelay ?? 300,
|
|
286
|
+
},
|
|
287
|
+
onOpenChange: (next, event) => {
|
|
288
|
+
if (next) root?.open(itemValue, event)
|
|
289
|
+
else if (root?.value === itemValue) root.close(event)
|
|
290
|
+
},
|
|
291
|
+
reference: view => view.trigger,
|
|
292
|
+
source: view => view.trigger,
|
|
293
|
+
referenceHidden: 'close',
|
|
294
|
+
dismiss: {
|
|
295
|
+
escape: false,
|
|
296
|
+
outside: true,
|
|
297
|
+
inside: view => [view.trigger, view.content],
|
|
298
|
+
onDismiss: event => closeItem(event),
|
|
299
|
+
},
|
|
300
|
+
onPosition: () => {
|
|
301
|
+
// Keyboard focus is committed only after the current trigger/content
|
|
302
|
+
// tuple has real geometry and the panel is visible.
|
|
303
|
+
if (root?.takeFocus(itemValue)) item.content?.querySelector<HTMLElement>(FOCUSABLE)?.focus()
|
|
304
|
+
},
|
|
305
|
+
onSync: opened => {
|
|
306
|
+
if (!opened) cause = ''
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Open cause decides what a trigger press does to an open panel: only a
|
|
311
|
+
// hover-opened panel holds through the press that follows (the
|
|
312
|
+
// hover→click race, the recorded deviation); a press- or keyboard-opened
|
|
313
|
+
// panel closes.
|
|
314
|
+
let cause: 'hover' | 'press' | '' = ''
|
|
315
|
+
|
|
316
|
+
const registerTriggerHover = (hovering: boolean, event: Event) => {
|
|
317
|
+
if (hovering) {
|
|
318
|
+
if (!item.open) cause = 'hover'
|
|
319
|
+
item.hold('trigger', event)
|
|
320
|
+
// Open-follows-hover: an already-open bar moves between panels
|
|
321
|
+
// without re-running the open delay.
|
|
322
|
+
if (!disabled) root?.follow(itemValue, event)
|
|
323
|
+
} else {
|
|
324
|
+
if (!item.open && cause === 'hover') cause = ''
|
|
325
|
+
item.release('trigger', event)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const registerContentHover = (hovering: boolean, event: Event) =>
|
|
330
|
+
hovering ? item.hold('content', event) : item.release('content', event)
|
|
331
|
+
|
|
332
|
+
const registerTriggerFocus = (event: FocusEvent) => {
|
|
333
|
+
if (!disabled) root?.follow(itemValue, event)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const openItem = (event?: Event) => {
|
|
337
|
+
if (disabled) return
|
|
338
|
+
cause = 'press'
|
|
339
|
+
item.cancelHover()
|
|
340
|
+
if (root) root.open(itemValue, event)
|
|
341
|
+
else item.setOpen(true, event)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const closeItem = (event?: Event) =>
|
|
345
|
+
root ? root.close(event) : item.close(event)
|
|
346
|
+
|
|
347
|
+
const clickTrigger = (event: Event) => {
|
|
348
|
+
if (disabled) return
|
|
349
|
+
if (!item.open) {
|
|
350
|
+
openItem(event)
|
|
351
|
+
} else if (cause === 'hover') {
|
|
352
|
+
cause = 'press'
|
|
353
|
+
item.cancelHover()
|
|
354
|
+
} else {
|
|
355
|
+
closeItem(event)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const args of this) {
|
|
360
|
+
root = RootContext()
|
|
361
|
+
itemValue = String(args.value ?? fallback)
|
|
362
|
+
disabled = Boolean(args.disabled)
|
|
363
|
+
// Without a NavigationMenu ancestor the item degrades to an
|
|
364
|
+
// uncontrolled hover panel instead of a permanently-closed one.
|
|
365
|
+
const opened = item.sync(root ? root.value === itemValue : null, {
|
|
366
|
+
placement: root?.placement,
|
|
367
|
+
gap: root?.gap,
|
|
368
|
+
})
|
|
369
|
+
if (!opened) cause = ''
|
|
370
|
+
|
|
371
|
+
ItemContext({
|
|
372
|
+
adoptTriggerId: item.adoptTriggerId,
|
|
373
|
+
clickTrigger,
|
|
374
|
+
close: closeItem,
|
|
375
|
+
contentId: item.contentId,
|
|
376
|
+
contentStyle: item.contentStyle,
|
|
377
|
+
disabled,
|
|
378
|
+
open: opened,
|
|
379
|
+
registerContentHover,
|
|
380
|
+
registerTriggerFocus,
|
|
381
|
+
registerTriggerHover,
|
|
382
|
+
setContent: item.setContent,
|
|
383
|
+
setTrigger: item.setTrigger,
|
|
384
|
+
triggerId: item.triggerId,
|
|
385
|
+
value: itemValue,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
yield <>{args.children}</>
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
NavigationMenuItemRoot.is = 'li'
|
|
393
|
+
|
|
394
|
+
/** Unstyled top-level item inside a NavigationMenuList. */
|
|
395
|
+
const NavigationMenuItem: Stateless<NavigationMenuItemArgs> = ({
|
|
396
|
+
children,
|
|
397
|
+
class: classes,
|
|
398
|
+
disabled,
|
|
399
|
+
value,
|
|
400
|
+
...attrs
|
|
401
|
+
}) => (
|
|
402
|
+
<NavigationMenuItemRoot
|
|
403
|
+
{...rootAttrs(attrs)}
|
|
404
|
+
disabled={disabled}
|
|
405
|
+
value={value}
|
|
406
|
+
attr:class={classes}
|
|
407
|
+
attr:data-disabled={disabled ? 'true' : undefined}
|
|
408
|
+
attr:data-slot="navigation-menu-item"
|
|
409
|
+
>
|
|
410
|
+
{children}
|
|
411
|
+
</NavigationMenuItemRoot>
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
/** Unstyled button that opens an item content panel. */
|
|
415
|
+
const NavigationMenuTrigger: Stateless<NavigationMenuTriggerArgs> = ({
|
|
416
|
+
children,
|
|
417
|
+
class: classes,
|
|
418
|
+
disabled,
|
|
419
|
+
id: idArg,
|
|
420
|
+
ref,
|
|
421
|
+
textValue,
|
|
422
|
+
type = 'button',
|
|
423
|
+
'set:onclick': onClick,
|
|
424
|
+
'set:onfocus': onFocus,
|
|
425
|
+
'set:onmouseenter': onMouseEnter,
|
|
426
|
+
'set:onmouseleave': onMouseLeave,
|
|
427
|
+
...attrs
|
|
428
|
+
}) => {
|
|
429
|
+
const item = ItemContext()
|
|
430
|
+
const disabledFlag = Boolean(disabled ?? item?.disabled)
|
|
431
|
+
const label = textValue ?? text(children)
|
|
432
|
+
const adoptedId = item?.adoptTriggerId(idArg)
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<button
|
|
436
|
+
{...attrs}
|
|
437
|
+
{...triggerAttrs({
|
|
438
|
+
controls: item?.contentId,
|
|
439
|
+
expanded: Boolean(item?.open),
|
|
440
|
+
id: adoptedId ?? idArg,
|
|
441
|
+
open: Boolean(item?.open),
|
|
442
|
+
ref,
|
|
443
|
+
setTrigger: item?.setTrigger,
|
|
444
|
+
triggerId: item?.triggerId,
|
|
445
|
+
})}
|
|
446
|
+
class={classes}
|
|
447
|
+
data-label={label}
|
|
448
|
+
data-navigation-menu-trigger="true"
|
|
449
|
+
data-slot="navigation-menu-trigger"
|
|
450
|
+
data-value={item?.value}
|
|
451
|
+
disabled={disabledFlag}
|
|
452
|
+
type={type}
|
|
453
|
+
set:onclick={(event: Event) => {
|
|
454
|
+
callHandler(onClick, event)
|
|
455
|
+
if (event.defaultPrevented || disabledFlag) return
|
|
456
|
+
// Clicking a closed trigger opens immediately; a hover-opened
|
|
457
|
+
// panel holds through its first click (the hover→click race);
|
|
458
|
+
// any other open panel closes.
|
|
459
|
+
item?.clickTrigger(event)
|
|
460
|
+
}}
|
|
461
|
+
set:onfocus={(event: FocusEvent) => {
|
|
462
|
+
callHandler(onFocus, event)
|
|
463
|
+
if (event.defaultPrevented || disabledFlag) return
|
|
464
|
+
item?.registerTriggerFocus(event)
|
|
465
|
+
}}
|
|
466
|
+
set:onmouseenter={(event: MouseEvent) => {
|
|
467
|
+
callHandler(onMouseEnter, event)
|
|
468
|
+
if (event.defaultPrevented || disabledFlag) return
|
|
469
|
+
item?.registerTriggerHover(true, event)
|
|
470
|
+
}}
|
|
471
|
+
set:onmouseleave={(event: MouseEvent) => {
|
|
472
|
+
callHandler(onMouseLeave, event)
|
|
473
|
+
item?.registerTriggerHover(false, event)
|
|
474
|
+
}}
|
|
475
|
+
>
|
|
476
|
+
{children}
|
|
477
|
+
</button>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Unstyled popover panel for a NavigationMenuItem. */
|
|
482
|
+
const NavigationMenuContent: Stateless<NavigationMenuContentArgs> = ({
|
|
483
|
+
children,
|
|
484
|
+
class: classes,
|
|
485
|
+
ref,
|
|
486
|
+
style,
|
|
487
|
+
'set:onmouseenter': onMouseEnter,
|
|
488
|
+
'set:onmouseleave': onMouseLeave,
|
|
489
|
+
...attrs
|
|
490
|
+
}) => {
|
|
491
|
+
const item = ItemContext()
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<div
|
|
495
|
+
{...attrs}
|
|
496
|
+
{...contentAttrs({
|
|
497
|
+
id: item?.contentId,
|
|
498
|
+
open: Boolean(item?.open),
|
|
499
|
+
ref,
|
|
500
|
+
setContent: item?.setContent,
|
|
501
|
+
style: item?.contentStyle(style),
|
|
502
|
+
tabindex: '-1',
|
|
503
|
+
})}
|
|
504
|
+
aria-labelledby={item?.triggerId}
|
|
505
|
+
class={classes}
|
|
506
|
+
data-slot="navigation-menu-content"
|
|
507
|
+
set:onmouseenter={(event: MouseEvent) => {
|
|
508
|
+
callHandler(onMouseEnter, event)
|
|
509
|
+
item?.registerContentHover(true, event)
|
|
510
|
+
}}
|
|
511
|
+
set:onmouseleave={(event: MouseEvent) => {
|
|
512
|
+
callHandler(onMouseLeave, event)
|
|
513
|
+
item?.registerContentHover(false, event)
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{children}
|
|
517
|
+
</div>
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Unstyled link for use inside or directly within a navigation menu item. */
|
|
522
|
+
const NavigationMenuLink: Stateless<NavigationMenuLinkArgs> = ({
|
|
523
|
+
active,
|
|
524
|
+
as = 'a',
|
|
525
|
+
children,
|
|
526
|
+
class: classes,
|
|
527
|
+
type = 'button',
|
|
528
|
+
'set:onclick': onClick,
|
|
529
|
+
...attrs
|
|
530
|
+
}) => {
|
|
531
|
+
const item = ItemContext()
|
|
532
|
+
const state = active ? 'true' : undefined
|
|
533
|
+
const current = active ? 'page' : undefined
|
|
534
|
+
const click = (event: Event) => {
|
|
535
|
+
callHandler(onClick, event)
|
|
536
|
+
if (!event.defaultPrevented) item?.close(event)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (as === 'button') {
|
|
540
|
+
return (
|
|
541
|
+
<button
|
|
542
|
+
{...attrs}
|
|
543
|
+
class={classes}
|
|
544
|
+
data-active={state}
|
|
545
|
+
data-slot="navigation-menu-link"
|
|
546
|
+
type={type}
|
|
547
|
+
set:onclick={click}
|
|
548
|
+
>
|
|
549
|
+
{children}
|
|
550
|
+
</button>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<a
|
|
556
|
+
{...attrs}
|
|
557
|
+
aria-current={current}
|
|
558
|
+
class={classes}
|
|
559
|
+
data-active={state}
|
|
560
|
+
data-slot="navigation-menu-link"
|
|
561
|
+
set:onclick={click}
|
|
562
|
+
>
|
|
563
|
+
{children}
|
|
564
|
+
</a>
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// NavigationMenu uses per-item anchored panels. Its themed surface lives on
|
|
569
|
+
// NavigationMenuContent; there is no shared Viewport or Indicator contract.
|
|
570
|
+
|
|
571
|
+
export {
|
|
572
|
+
NavigationMenu,
|
|
573
|
+
NavigationMenuContent,
|
|
574
|
+
NavigationMenuItem,
|
|
575
|
+
NavigationMenuLink,
|
|
576
|
+
NavigationMenuList,
|
|
577
|
+
NavigationMenuTrigger,
|
|
578
|
+
}
|