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,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
+ }