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/tooltip.tsx
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import { contentAttrs, popup, type PopupView } from './popup'
|
|
5
|
+
import { PopupSurface } from './popup-surface'
|
|
6
|
+
import type { ReservedPositionArg } from './position'
|
|
7
|
+
import { popupStyle, triggerAttrs, type FixedArgs, type OmitArg, type PopupPosition } from './utils'
|
|
8
|
+
export type { PopupPlacement, PopupPosition } from './utils'
|
|
9
|
+
|
|
10
|
+
/** Props for shared timing and hover defaults inherited by tooltips. */
|
|
11
|
+
export type TooltipProviderArgs = WithChildren<IntrinsicElements['div'] & {
|
|
12
|
+
/** Delay before a tooltip opens, in milliseconds. */
|
|
13
|
+
delayDuration?: number
|
|
14
|
+
/** Delay window after a tooltip closes. */
|
|
15
|
+
skipDelayDuration?: number
|
|
16
|
+
/** Close immediately when pointer leaves the trigger instead of allowing hover over the content. */
|
|
17
|
+
disableHoverableContent?: boolean
|
|
18
|
+
/** Additional CSS classes. */
|
|
19
|
+
class?: string
|
|
20
|
+
/** Inline CSS string. */
|
|
21
|
+
style?: string
|
|
22
|
+
}>
|
|
23
|
+
|
|
24
|
+
/** Props for the tooltip root and its controlled open state. */
|
|
25
|
+
export type TooltipArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'onchange' | ReservedPositionArg> & PopupPosition & {
|
|
26
|
+
/** Controlled open state. */
|
|
27
|
+
open?: boolean
|
|
28
|
+
/** Initial open state for uncontrolled usage. */
|
|
29
|
+
defaultOpen?: boolean
|
|
30
|
+
/** Disable trigger activation. */
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
/** Delay before this tooltip opens, in milliseconds. */
|
|
33
|
+
delayDuration?: number
|
|
34
|
+
/** Close immediately when pointer leaves the trigger instead of allowing hover over the content. */
|
|
35
|
+
disableHoverableContent?: boolean
|
|
36
|
+
/** Called whenever the tooltip opens or closes. */
|
|
37
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
38
|
+
/** Additional CSS classes. */
|
|
39
|
+
class?: string
|
|
40
|
+
}> & FixedArgs<'onchange' | ReservedPositionArg>
|
|
41
|
+
|
|
42
|
+
/** Props for the button or span that opens a tooltip. */
|
|
43
|
+
export type TooltipTriggerArgs = WithChildren<(IntrinsicElements['button'] & IntrinsicElements['span']) & {
|
|
44
|
+
/** Render the trigger wrapper as a button or span. */
|
|
45
|
+
as?: 'button' | 'span'
|
|
46
|
+
/** Additional CSS classes. */
|
|
47
|
+
class?: string
|
|
48
|
+
}>
|
|
49
|
+
|
|
50
|
+
/** Props for the positioned tooltip panel. */
|
|
51
|
+
export type TooltipContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'align' | 'arrow' | 'id' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
|
|
52
|
+
/** Additional CSS classes. */
|
|
53
|
+
class?: string
|
|
54
|
+
/** Inline CSS string. */
|
|
55
|
+
style?: string
|
|
56
|
+
}> & FixedArgs<'arrow' | 'gap' | 'id' | 'placement' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
|
|
57
|
+
|
|
58
|
+
type ProviderContextValue = {
|
|
59
|
+
delayDuration: number
|
|
60
|
+
disableHoverableContent: boolean
|
|
61
|
+
markClosed: () => void
|
|
62
|
+
shouldSkipDelay: () => boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type TooltipContextValue = {
|
|
66
|
+
arrowAttrs: PopupView['arrowAttrs']
|
|
67
|
+
contentId: string
|
|
68
|
+
contentStyle: PopupView['contentStyle']
|
|
69
|
+
disabled: boolean
|
|
70
|
+
open: boolean
|
|
71
|
+
registerContentHover: (hovering: boolean, event?: Event) => void
|
|
72
|
+
registerFocus: (focused: boolean, event?: Event) => void
|
|
73
|
+
registerTriggerHover: (hovering: boolean, event?: Event) => void
|
|
74
|
+
setContent: (element: HTMLDivElement | null) => void
|
|
75
|
+
setTrigger: (element: HTMLElement | null) => void
|
|
76
|
+
triggerId: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ProviderContext = context<ProviderContextValue | null>(null)
|
|
80
|
+
const TooltipContext = context<TooltipContextValue | null>(null)
|
|
81
|
+
|
|
82
|
+
const TooltipProviderRoot: Stateful<TooltipProviderArgs> = function* () {
|
|
83
|
+
let lastClosedAt = 0
|
|
84
|
+
let skipDelayDuration = 300
|
|
85
|
+
|
|
86
|
+
for (const args of this) {
|
|
87
|
+
skipDelayDuration = Math.max(0, Number(args.skipDelayDuration ?? 300))
|
|
88
|
+
|
|
89
|
+
ProviderContext({
|
|
90
|
+
delayDuration: Math.max(0, Number(args.delayDuration ?? 0)),
|
|
91
|
+
disableHoverableContent: Boolean(args.disableHoverableContent),
|
|
92
|
+
markClosed: () => lastClosedAt = Date.now(),
|
|
93
|
+
shouldSkipDelay: () => skipDelayDuration > 0 && Date.now() - lastClosedAt < skipDelayDuration,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
yield <>{args.children}</>
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/** Unstyled shared defaults provider for descendant tooltips. */
|
|
102
|
+
const TooltipProvider: Stateless<TooltipProviderArgs> = ({
|
|
103
|
+
children,
|
|
104
|
+
class: classes,
|
|
105
|
+
'data-slot': slot = 'tooltip-provider',
|
|
106
|
+
delayDuration,
|
|
107
|
+
disableHoverableContent,
|
|
108
|
+
skipDelayDuration,
|
|
109
|
+
style,
|
|
110
|
+
...attrs
|
|
111
|
+
}) => (
|
|
112
|
+
<TooltipProviderRoot
|
|
113
|
+
{...rootAttrs(attrs)}
|
|
114
|
+
delayDuration={delayDuration}
|
|
115
|
+
disableHoverableContent={disableHoverableContent}
|
|
116
|
+
skipDelayDuration={skipDelayDuration}
|
|
117
|
+
attr:class={classes}
|
|
118
|
+
attr:data-slot={slot}
|
|
119
|
+
attr:style={style}
|
|
120
|
+
>
|
|
121
|
+
{children}
|
|
122
|
+
</TooltipProviderRoot>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const TooltipRoot: Stateful<TooltipArgs> = function* ({ defaultOpen, open }) {
|
|
126
|
+
let delayDuration = 0
|
|
127
|
+
let disabled = false
|
|
128
|
+
let disableHoverableContent = false
|
|
129
|
+
let onOpenChange: TooltipArgs['onOpenChange']
|
|
130
|
+
let provider: ProviderContextValue | null = null
|
|
131
|
+
let tip: PopupView<HTMLElement, HTMLDivElement>
|
|
132
|
+
|
|
133
|
+
tip = popup<HTMLElement, HTMLDivElement>(this, {
|
|
134
|
+
prefix: 'tooltip',
|
|
135
|
+
profile: 'tooltip',
|
|
136
|
+
initialOpen: Boolean(open ?? defaultOpen),
|
|
137
|
+
disabled: () => disabled,
|
|
138
|
+
hover: {
|
|
139
|
+
openDelay: () => delayDuration <= 0 || provider?.shouldSkipDelay() ? 0 : delayDuration,
|
|
140
|
+
closeDelay: () => disableHoverableContent ? 0 : 80,
|
|
141
|
+
},
|
|
142
|
+
onOpenChange: (next, event) => onOpenChange?.(next, event),
|
|
143
|
+
onSync: next => {
|
|
144
|
+
if (!next) provider?.markClosed()
|
|
145
|
+
},
|
|
146
|
+
referenceHidden: 'hide',
|
|
147
|
+
dismiss: {
|
|
148
|
+
prevent: true,
|
|
149
|
+
onDismiss: (event, view) => {
|
|
150
|
+
view.cancelHover()
|
|
151
|
+
view.setOpen(false, event)
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const registerContentHover = (hovering: boolean, event?: Event) => {
|
|
157
|
+
if (hovering) {
|
|
158
|
+
if (!disableHoverableContent) tip.hold('content', event as Event)
|
|
159
|
+
}
|
|
160
|
+
else tip.release('content', event as Event)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const registerTriggerHover = (hovering: boolean, event?: Event) => {
|
|
164
|
+
if (hovering) tip.hold('trigger', event as Event)
|
|
165
|
+
else tip.release('trigger', event as Event)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const registerFocus = (next: boolean, event?: Event) => {
|
|
169
|
+
if (next) tip.hold('focus', event as Event)
|
|
170
|
+
else tip.release('focus', event as Event)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const args of this) {
|
|
174
|
+
provider = ProviderContext()
|
|
175
|
+
delayDuration = Math.max(0, Number(args.delayDuration ?? provider?.delayDuration ?? 0))
|
|
176
|
+
disabled = Boolean(args.disabled)
|
|
177
|
+
disableHoverableContent = Boolean(args.disableHoverableContent ?? provider?.disableHoverableContent)
|
|
178
|
+
onOpenChange = args.onOpenChange
|
|
179
|
+
const opened = tip.sync(args.open == null ? null : Boolean(args.open), {
|
|
180
|
+
placement: args.placement,
|
|
181
|
+
gap: args.gap,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
TooltipContext({
|
|
185
|
+
...tip,
|
|
186
|
+
disabled,
|
|
187
|
+
open: opened,
|
|
188
|
+
registerContentHover,
|
|
189
|
+
registerFocus,
|
|
190
|
+
registerTriggerHover,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
yield <>{args.children}</>
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
/** Unstyled root provider for one tooltip. */
|
|
199
|
+
const Tooltip: Stateless<TooltipArgs> = ({
|
|
200
|
+
children,
|
|
201
|
+
class: classes,
|
|
202
|
+
'data-slot': slot = 'tooltip',
|
|
203
|
+
defaultOpen,
|
|
204
|
+
delayDuration,
|
|
205
|
+
disabled,
|
|
206
|
+
disableHoverableContent,
|
|
207
|
+
gap,
|
|
208
|
+
onOpenChange,
|
|
209
|
+
open,
|
|
210
|
+
placement,
|
|
211
|
+
...attrs
|
|
212
|
+
}) => (
|
|
213
|
+
<TooltipRoot
|
|
214
|
+
{...rootAttrs(attrs)}
|
|
215
|
+
defaultOpen={defaultOpen}
|
|
216
|
+
delayDuration={delayDuration}
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
disableHoverableContent={disableHoverableContent}
|
|
219
|
+
gap={gap}
|
|
220
|
+
onOpenChange={onOpenChange}
|
|
221
|
+
open={open}
|
|
222
|
+
placement={placement}
|
|
223
|
+
attr:class={classes}
|
|
224
|
+
attr:data-slot={slot}
|
|
225
|
+
>
|
|
226
|
+
{children}
|
|
227
|
+
</TooltipRoot>
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
/** Unstyled element that owns the tooltip description. */
|
|
231
|
+
const TooltipTrigger: Stateless<TooltipTriggerArgs> = ({
|
|
232
|
+
as = 'button',
|
|
233
|
+
children,
|
|
234
|
+
class: classes,
|
|
235
|
+
'data-slot': slot = 'tooltip-trigger',
|
|
236
|
+
disabled,
|
|
237
|
+
id,
|
|
238
|
+
ref,
|
|
239
|
+
type = 'button',
|
|
240
|
+
'aria-describedby': describedBy,
|
|
241
|
+
'set:onblur': onBlur,
|
|
242
|
+
'set:onfocus': onFocus,
|
|
243
|
+
'set:onkeydown': onKeydown,
|
|
244
|
+
'set:onmouseleave': onMouseLeave,
|
|
245
|
+
'set:onmouseenter': onMouseEnter,
|
|
246
|
+
...attrs
|
|
247
|
+
}) => {
|
|
248
|
+
const tooltip = TooltipContext()
|
|
249
|
+
const disabledFlag = Boolean(disabled ?? tooltip?.disabled)
|
|
250
|
+
const descriptions = [...new Set(
|
|
251
|
+
`${describedBy ?? ''} ${tooltip?.contentId ?? ''}`.trim().split(/\s+/).filter(Boolean),
|
|
252
|
+
)].join(' ') || undefined
|
|
253
|
+
|
|
254
|
+
const common = {
|
|
255
|
+
...attrs,
|
|
256
|
+
...triggerAttrs({
|
|
257
|
+
describedby: descriptions,
|
|
258
|
+
id,
|
|
259
|
+
open: Boolean(tooltip?.open),
|
|
260
|
+
ref,
|
|
261
|
+
setTrigger: tooltip?.setTrigger,
|
|
262
|
+
triggerId: tooltip?.triggerId,
|
|
263
|
+
}),
|
|
264
|
+
class: classes,
|
|
265
|
+
'data-slot': slot,
|
|
266
|
+
'set:onblur': (event: FocusEvent) => {
|
|
267
|
+
callHandler(onBlur, event)
|
|
268
|
+
tooltip?.registerFocus(false, event)
|
|
269
|
+
},
|
|
270
|
+
'set:onfocus': (event: FocusEvent) => {
|
|
271
|
+
callHandler(onFocus, event)
|
|
272
|
+
if (event.defaultPrevented || disabledFlag) return
|
|
273
|
+
tooltip?.registerFocus(true, event)
|
|
274
|
+
},
|
|
275
|
+
'set:onkeydown': (event: KeyboardEvent) => {
|
|
276
|
+
callHandler(onKeydown, event)
|
|
277
|
+
},
|
|
278
|
+
'set:onmouseleave': (event: MouseEvent) => {
|
|
279
|
+
callHandler(onMouseLeave, event)
|
|
280
|
+
tooltip?.registerTriggerHover(false, event)
|
|
281
|
+
},
|
|
282
|
+
'set:onmouseenter': (event: MouseEvent) => {
|
|
283
|
+
callHandler(onMouseEnter, event)
|
|
284
|
+
if (event.defaultPrevented || disabledFlag) return
|
|
285
|
+
tooltip?.registerTriggerHover(true, event)
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (as === 'span') {
|
|
290
|
+
return (
|
|
291
|
+
<span
|
|
292
|
+
{...common}
|
|
293
|
+
aria-disabled={disabledFlag ? 'true' : undefined}
|
|
294
|
+
>
|
|
295
|
+
{children}
|
|
296
|
+
</span>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<button
|
|
302
|
+
{...common}
|
|
303
|
+
disabled={disabledFlag}
|
|
304
|
+
type={type}
|
|
305
|
+
>
|
|
306
|
+
{children}
|
|
307
|
+
</button>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Unstyled non-interactive text bubble shown for a TooltipTrigger. */
|
|
312
|
+
const TooltipContent: Stateless<TooltipContentArgs> = ({
|
|
313
|
+
children,
|
|
314
|
+
class: classes,
|
|
315
|
+
'data-slot': slot = 'tooltip-content',
|
|
316
|
+
ref,
|
|
317
|
+
style,
|
|
318
|
+
'set:onmouseleave': onMouseLeave,
|
|
319
|
+
'set:onmouseenter': onMouseEnter,
|
|
320
|
+
...attrs
|
|
321
|
+
}) => {
|
|
322
|
+
const tooltip = TooltipContext()
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
{...attrs}
|
|
327
|
+
{...contentAttrs({
|
|
328
|
+
id: tooltip?.contentId,
|
|
329
|
+
open: Boolean(tooltip?.open),
|
|
330
|
+
ref,
|
|
331
|
+
setContent: tooltip?.setContent,
|
|
332
|
+
style: tooltip?.contentStyle(style) ?? popupStyle(style),
|
|
333
|
+
tabindex: undefined,
|
|
334
|
+
})}
|
|
335
|
+
class={classes}
|
|
336
|
+
data-arrow="true"
|
|
337
|
+
data-slot={slot}
|
|
338
|
+
role="tooltip"
|
|
339
|
+
set:onmouseleave={(event: MouseEvent) => {
|
|
340
|
+
callHandler(onMouseLeave, event)
|
|
341
|
+
tooltip?.registerContentHover(false, event)
|
|
342
|
+
}}
|
|
343
|
+
set:onmouseenter={(event: MouseEvent) => {
|
|
344
|
+
callHandler(onMouseEnter, event)
|
|
345
|
+
tooltip?.registerContentHover(true, event)
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<PopupSurface arrow popup={tooltip} />
|
|
349
|
+
{children}
|
|
350
|
+
</div>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export {
|
|
355
|
+
Tooltip,
|
|
356
|
+
TooltipContent,
|
|
357
|
+
TooltipProvider,
|
|
358
|
+
TooltipTrigger,
|
|
359
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { Stateless } from 'ajo'
|
|
2
|
+
import { callRef } from 'ajo-cloves'
|
|
3
|
+
import { jsx } from 'ajo/jsx-runtime'
|
|
4
|
+
|
|
5
|
+
type SlotArgs = Record<string, unknown> & { 'data-slot'?: string }
|
|
6
|
+
|
|
7
|
+
/** Logical placement shared by Ajo popup roots. */
|
|
8
|
+
export type PopupPlacement =
|
|
9
|
+
| 'top' | 'top-start' | 'top-end'
|
|
10
|
+
| 'right' | 'right-start' | 'right-end'
|
|
11
|
+
| 'bottom' | 'bottom-start' | 'bottom-end'
|
|
12
|
+
| 'left' | 'left-start' | 'left-end'
|
|
13
|
+
| 'auto'
|
|
14
|
+
|
|
15
|
+
/** Small semantic positioning contract shared by Ajo popup roots. */
|
|
16
|
+
export type PopupPosition = {
|
|
17
|
+
/** Preferred logical placement; auto chooses the most available space. */
|
|
18
|
+
placement?: PopupPlacement
|
|
19
|
+
/** Distance between the reference and floating box, in CSS pixels. */
|
|
20
|
+
gap?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type TriggerAttrsOptions<Element extends HTMLElement> = {
|
|
24
|
+
controls?: string
|
|
25
|
+
describedby?: string
|
|
26
|
+
expanded?: boolean
|
|
27
|
+
haspopup?: 'dialog' | 'listbox' | 'menu'
|
|
28
|
+
id?: unknown
|
|
29
|
+
open: boolean
|
|
30
|
+
ref?: unknown
|
|
31
|
+
setTrigger?: (element: Element | null) => void
|
|
32
|
+
triggerId?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Builds state, popup relations, ids and a composed trigger ref. */
|
|
36
|
+
export const triggerAttrs = <Element extends HTMLElement>(options: TriggerAttrsOptions<Element>): Record<string, unknown> => {
|
|
37
|
+
const { controls, describedby, expanded, haspopup, id, open, ref, setTrigger, triggerId } = options
|
|
38
|
+
const attrs: Record<string, unknown> = { 'data-state': open ? 'open' : 'closed' }
|
|
39
|
+
if ('controls' in options) attrs['aria-controls'] = controls
|
|
40
|
+
if ('describedby' in options) attrs['aria-describedby'] = describedby
|
|
41
|
+
if ('expanded' in options) attrs['aria-expanded'] = expanded ? 'true' : 'false'
|
|
42
|
+
if ('haspopup' in options) attrs['aria-haspopup'] = haspopup
|
|
43
|
+
if ('id' in options || 'triggerId' in options) attrs.id = id ?? triggerId
|
|
44
|
+
if ('ref' in options || 'setTrigger' in options) {
|
|
45
|
+
attrs.ref = (element: Element | null) => {
|
|
46
|
+
setTrigger?.(element)
|
|
47
|
+
callRef(ref, element)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return attrs
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inline reset for a native popover plus caller-owned declarations. */
|
|
54
|
+
export const popupStyle = (...parts: unknown[]) =>
|
|
55
|
+
['inset:auto', 'margin:0', ...parts.filter((part): part is string => typeof part === 'string' && part.length > 0)].join(';')
|
|
56
|
+
|
|
57
|
+
/** Omits named arguments without collapsing Ajo's open Args index signature. */
|
|
58
|
+
export type OmitArg<T, Keys extends PropertyKey> = {
|
|
59
|
+
[Key in keyof T as Key extends Keys ? never : Key]: T[Key]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Marks component arguments owned by an adapter as unavailable to callers. */
|
|
63
|
+
export type FixedArgs<Keys extends PropertyKey> = {
|
|
64
|
+
[Key in Keys]?: never
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wraps a re-exported family part with a fixed data-slot and optional defaults. */
|
|
68
|
+
export const withSlot = <Args extends SlotArgs>(
|
|
69
|
+
Component: Stateless<Args>,
|
|
70
|
+
slot: string,
|
|
71
|
+
defaults?: Partial<Args>,
|
|
72
|
+
): Stateless<Args> => attrs => jsx(Component, { ...defaults, ...attrs, 'data-slot': slot })
|
|
73
|
+
|
|
74
|
+
/** Marks a boolean state attr: 'true' when set, absent otherwise. */
|
|
75
|
+
export const flag = (value: unknown) => value ? 'true' : undefined
|
|
76
|
+
|
|
77
|
+
/** Parses boolean-ish attr input (true, '', 'true'). */
|
|
78
|
+
export const bool = (value: unknown) =>
|
|
79
|
+
value === true || value === '' || value === 'true'
|
|
80
|
+
|
|
81
|
+
/** Normalized state shared by native and visual checked controls. */
|
|
82
|
+
export type CheckedState = 'checked' | 'indeterminate' | 'unchecked'
|
|
83
|
+
|
|
84
|
+
/** Maps a checked-state token to the native aria-checked vocabulary. */
|
|
85
|
+
export const ariaChecked = (state: CheckedState) =>
|
|
86
|
+
state === 'indeterminate' ? 'mixed' : state === 'checked' ? 'true' : 'false'
|
|
87
|
+
|
|
88
|
+
/** Mirrors a native input's live checked state onto it and one visual companion. */
|
|
89
|
+
export const syncCheckedState = (input: HTMLInputElement, companion?: HTMLElement | null): CheckedState => {
|
|
90
|
+
const state = input.type === 'checkbox' && input.indeterminate
|
|
91
|
+
? 'indeterminate'
|
|
92
|
+
: input.checked ? 'checked' : 'unchecked'
|
|
93
|
+
input.dataset.state = state
|
|
94
|
+
input.setAttribute('aria-checked', ariaChecked(state))
|
|
95
|
+
if (companion) companion.dataset.state = state
|
|
96
|
+
return state
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Flattens JSX children into their concatenated plain-text content. */
|
|
100
|
+
export const text = (value: unknown): string => {
|
|
101
|
+
if (value == null || value === false) return ''
|
|
102
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
103
|
+
if (Array.isArray(value)) return value.map(text).join('')
|
|
104
|
+
return ''
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Copies a multi-value array while coercing each present value to a string. */
|
|
108
|
+
export const strings = (value: unknown): string[] =>
|
|
109
|
+
Array.isArray(value) ? value.map(String) : []
|
|
110
|
+
|
|
111
|
+
/** Token match: every whitespace-separated search token appears in the haystack, case-insensitive; an empty search matches. */
|
|
112
|
+
export const matchesTokens = (search: string, haystack: string) => {
|
|
113
|
+
const query = search.trim().toLowerCase()
|
|
114
|
+
if (!query) return true
|
|
115
|
+
const target = haystack.trim().toLowerCase()
|
|
116
|
+
return query.split(/\s+/).every(part => target.includes(part))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Formats the default English result count for filterable collections. */
|
|
120
|
+
export const defaultResultsLabel = (count: number) =>
|
|
121
|
+
`${count} result${count === 1 ? '' : 's'}`
|
|
122
|
+
|
|
123
|
+
const passAll = () => true
|
|
124
|
+
|
|
125
|
+
/** Resolves undefined to the built-in filter, null to unfiltered, and preserves a custom filter. */
|
|
126
|
+
export const resolveFilter = <Args extends unknown[]>(
|
|
127
|
+
filter: ((...args: Args) => boolean) | null | undefined,
|
|
128
|
+
fallback: (...args: Args) => boolean,
|
|
129
|
+
): ((...args: Args) => boolean) =>
|
|
130
|
+
filter === undefined ? fallback : filter ?? passAll
|
|
131
|
+
|
|
132
|
+
/** Joins conditional class names, returning undefined when empty. */
|
|
133
|
+
export const clx = (...values: Array<string | false | null | undefined>) =>
|
|
134
|
+
values.filter(Boolean).join(' ') || undefined
|
|
135
|
+
|
|
136
|
+
/** Coerces to a finite number, falling back otherwise. */
|
|
137
|
+
export const toNumber = (value: unknown, fallback: number) => {
|
|
138
|
+
const next = Number(value)
|
|
139
|
+
return Number.isFinite(next) ? next : fallback
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Returns true when JSX children carry no visible content. */
|
|
143
|
+
export const emptyChildren = (children: unknown) =>
|
|
144
|
+
children == null ||
|
|
145
|
+
children === false ||
|
|
146
|
+
(Array.isArray(children) && children.every(child => child == null || child === false))
|
|
147
|
+
|
|
148
|
+
type DomStyleProperty = Exclude<{
|
|
149
|
+
[Key in keyof CSSStyleDeclaration]-?: Key extends string
|
|
150
|
+
? CSSStyleDeclaration[Key] extends string ? Key : never
|
|
151
|
+
: never
|
|
152
|
+
}[keyof CSSStyleDeclaration], 'cssText'>
|
|
153
|
+
|
|
154
|
+
type KebabCase<Value extends string> = Value extends `${infer Head}${infer Tail}`
|
|
155
|
+
? Head extends Lowercase<Head>
|
|
156
|
+
? `${Head}${KebabCase<Tail>}`
|
|
157
|
+
: `-${Lowercase<Head>}${KebabCase<Tail>}`
|
|
158
|
+
: Value
|
|
159
|
+
|
|
160
|
+
type StyleProperty = DomStyleProperty | KebabCase<DomStyleProperty>
|
|
161
|
+
|
|
162
|
+
/** Primitive value accepted by one inline style property. */
|
|
163
|
+
export type StyleValue = string | number | false | null | undefined
|
|
164
|
+
|
|
165
|
+
/** Typed inline style properties, including custom CSS variables. */
|
|
166
|
+
export type StyleObject = {
|
|
167
|
+
[Key in StyleProperty]?: StyleValue
|
|
168
|
+
} & {
|
|
169
|
+
[Key in `--${string}`]?: StyleValue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Recursive input accepted by the inline style serializer. */
|
|
173
|
+
export type StyleInput = StyleValue | true | StyleObject | readonly StyleInput[]
|
|
174
|
+
|
|
175
|
+
const property = (key: string) => {
|
|
176
|
+
if (key === 'cssFloat') return 'float'
|
|
177
|
+
if (key.startsWith('--')) return key
|
|
178
|
+
const css = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
|
|
179
|
+
return css.startsWith('ms-') ? `-${css}` : css
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const collectStyles = (input: StyleInput, result: string[]) => {
|
|
183
|
+
if (!input || input === true || typeof input === 'number') return
|
|
184
|
+
if (typeof input === 'string') {
|
|
185
|
+
const value = input.trim().replace(/;+$/, '')
|
|
186
|
+
if (value) result.push(value)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(input)) {
|
|
190
|
+
for (const item of input) collectStyles(item, result)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
for (const [key, value] of Object.entries(input)) {
|
|
194
|
+
if (value == null || value === false) continue
|
|
195
|
+
result.push(`${property(key)}:${value}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Build an inline style string from strings, objects, arrays, and falsey entries. */
|
|
200
|
+
export const stlx = (...input: StyleInput[]) => {
|
|
201
|
+
const result: string[] = []
|
|
202
|
+
for (const item of input) collectStyles(item, result)
|
|
203
|
+
return result.join(';')
|
|
204
|
+
}
|