@santi020k/lumen-astro 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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/components/Accordion.astro +27 -0
  4. package/components/Affix.astro +41 -0
  5. package/components/Agenda.astro +19 -0
  6. package/components/Alert.astro +38 -0
  7. package/components/AlertDialog.astro +41 -0
  8. package/components/Anchor.astro +43 -0
  9. package/components/AnimatedLogo.astro +30 -0
  10. package/components/AnimatedNumber.astro +56 -0
  11. package/components/AnimatedPortrait.astro +124 -0
  12. package/components/AspectRatio.astro +39 -0
  13. package/components/Attachment.astro +46 -0
  14. package/components/Autocomplete.astro +24 -0
  15. package/components/Avatar.astro +36 -0
  16. package/components/BackToTop.astro +30 -0
  17. package/components/Badge.astro +36 -0
  18. package/components/Breadcrumb.astro +28 -0
  19. package/components/Bubble.astro +36 -0
  20. package/components/Button.astro +52 -0
  21. package/components/ButtonGroup.astro +27 -0
  22. package/components/ButtonLink.astro +58 -0
  23. package/components/Calendar.astro +197 -0
  24. package/components/Callout.astro +27 -0
  25. package/components/Card.astro +40 -0
  26. package/components/Carousel.astro +33 -0
  27. package/components/Cascader.astro +109 -0
  28. package/components/Chart.astro +32 -0
  29. package/components/Checkbox.astro +22 -0
  30. package/components/Code.astro +96 -0
  31. package/components/Collapsible.astro +28 -0
  32. package/components/ColorPicker.astro +16 -0
  33. package/components/Combobox.astro +67 -0
  34. package/components/Command.astro +33 -0
  35. package/components/ContextMenu.astro +44 -0
  36. package/components/CoverImage.astro +180 -0
  37. package/components/DataTable.astro +122 -0
  38. package/components/DatePicker.astro +108 -0
  39. package/components/DateRangePicker.astro +18 -0
  40. package/components/Descriptions.astro +41 -0
  41. package/components/Dialog.astro +40 -0
  42. package/components/Direction.astro +30 -0
  43. package/components/Drawer.astro +40 -0
  44. package/components/DropdownMenu.astro +40 -0
  45. package/components/Empty.astro +32 -0
  46. package/components/Eyebrow.astro +27 -0
  47. package/components/Field.astro +42 -0
  48. package/components/FileUpload.astro +44 -0
  49. package/components/FloatingBadge.astro +27 -0
  50. package/components/FormattedDate.astro +27 -0
  51. package/components/GradientDivider.astro +24 -0
  52. package/components/HoverCard.astro +40 -0
  53. package/components/Icon.astro +50 -0
  54. package/components/Image.astro +33 -0
  55. package/components/Input.astro +24 -0
  56. package/components/InputGroup.astro +27 -0
  57. package/components/InputOTP.astro +60 -0
  58. package/components/Item.astro +32 -0
  59. package/components/Kbd.astro +27 -0
  60. package/components/Label.astro +18 -0
  61. package/components/LanguageToggle.astro +30 -0
  62. package/components/Link.astro +27 -0
  63. package/components/Marker.astro +33 -0
  64. package/components/Mentions.astro +47 -0
  65. package/components/Menubar.astro +40 -0
  66. package/components/Message.astro +32 -0
  67. package/components/MessageScroller.astro +32 -0
  68. package/components/Meter.astro +31 -0
  69. package/components/NativeSelect.astro +37 -0
  70. package/components/NavigationMenu.astro +40 -0
  71. package/components/Note.astro +23 -0
  72. package/components/NumberField.astro +16 -0
  73. package/components/Pagination.astro +28 -0
  74. package/components/Particles.astro +31 -0
  75. package/components/PhoneInput.astro +86 -0
  76. package/components/Pill.astro +30 -0
  77. package/components/Popconfirm.astro +40 -0
  78. package/components/Popover.astro +40 -0
  79. package/components/Progress.astro +30 -0
  80. package/components/Prose.astro +27 -0
  81. package/components/QRCode.astro +44 -0
  82. package/components/RadioGroup.astro +28 -0
  83. package/components/Rating.astro +42 -0
  84. package/components/Resizable.astro +55 -0
  85. package/components/RevealGroup.astro +54 -0
  86. package/components/RichTextEditor.astro +19 -0
  87. package/components/Schedule.astro +19 -0
  88. package/components/ScrollArea.astro +32 -0
  89. package/components/ScrollReveal.astro +50 -0
  90. package/components/SearchField.astro +16 -0
  91. package/components/Segmented.astro +56 -0
  92. package/components/Select.astro +120 -0
  93. package/components/Separator.astro +31 -0
  94. package/components/Sheet.astro +40 -0
  95. package/components/Sidebar.astro +39 -0
  96. package/components/Skeleton.astro +36 -0
  97. package/components/SkipLink.astro +27 -0
  98. package/components/Slider.astro +20 -0
  99. package/components/Sonner.astro +38 -0
  100. package/components/SpeedDial.astro +77 -0
  101. package/components/Spinner.astro +36 -0
  102. package/components/Stat.astro +34 -0
  103. package/components/Stepper.astro +57 -0
  104. package/components/Switch.astro +23 -0
  105. package/components/Table.astro +32 -0
  106. package/components/Tabs.astro +33 -0
  107. package/components/TagGroup.astro +18 -0
  108. package/components/Textarea.astro +17 -0
  109. package/components/ThemeBuilder.astro +19 -0
  110. package/components/ThemeToggle.astro +41 -0
  111. package/components/TimeField.astro +16 -0
  112. package/components/Timeline.astro +12 -0
  113. package/components/TimelineItem.astro +19 -0
  114. package/components/Toast.astro +50 -0
  115. package/components/Toggle.astro +25 -0
  116. package/components/ToggleGroup.astro +28 -0
  117. package/components/Toolbar.astro +36 -0
  118. package/components/Tooltip.astro +27 -0
  119. package/components/Tour.astro +52 -0
  120. package/components/Transfer.astro +70 -0
  121. package/components/Tree.astro +19 -0
  122. package/components/TreeGrid.astro +19 -0
  123. package/components/TreeSelect.astro +69 -0
  124. package/components/Typography.astro +27 -0
  125. package/components/VirtualList.astro +19 -0
  126. package/components/Watermark.astro +60 -0
  127. package/index.d.ts +125 -0
  128. package/index.ts +125 -0
  129. package/package.json +74 -0
  130. package/runtime/UIPrimitives.astro +4649 -0
  131. package/styles/lumen.css +5 -0
@@ -0,0 +1,4649 @@
1
+ ---
2
+ /**
3
+ * Astro UI primitives controller.
4
+ *
5
+ * Provides progressive enhancement for local UI components without
6
+ * adding a framework runtime.
7
+ */
8
+ ---
9
+
10
+ <script>
11
+ import {
12
+ createThemeFromHue,
13
+ exportThemeCss,
14
+ exportThemeDesignTokens,
15
+ exportThemeFigmaVariables,
16
+ getLumenRichTextShortcut,
17
+ isLumenRichTextToggleCommand,
18
+ type LumenRichTextChangeDetail,
19
+ type LumenRichTextCommandDetail,
20
+ type LumenThemeTokens,
21
+ lumenTokenNames
22
+ } from '@santi020k/lumen-core'
23
+
24
+ /* eslint-disable complexity, unicorn/consistent-function-scoping -- The runtime keeps stateful DOM handlers close to each primitive so Astro consumers do not need a framework runtime. */
25
+ type ThemeBuilderExportFormat = 'css' | 'figma' | 'tokens'
26
+
27
+ type ThemeBuilderMode = 'generated' | 'manual'
28
+
29
+ type ThemeBuilderScheme = 'dark' | 'light'
30
+
31
+ interface RichTextCommandDocument {
32
+ execCommand?: (command: string, showUi?: boolean, value?: string) => boolean
33
+ queryCommandState?: (command: string) => boolean
34
+ queryCommandValue?: (command: string) => string
35
+ }
36
+
37
+ const focusableSelector = [
38
+ 'a[href]',
39
+ 'button:not([disabled])',
40
+ 'input:not([disabled])',
41
+ 'select:not([disabled])',
42
+ 'textarea:not([disabled])',
43
+ '[tabindex]:not([tabindex="-1"])'
44
+ ].join(',')
45
+
46
+ const disclosureSelector = '[data-ui-popover], [data-ui-dropdown-menu], [data-ui-hover-card]'
47
+
48
+ const fieldControlSelector = [
49
+ 'input:not([type="hidden"])',
50
+ 'select',
51
+ 'textarea',
52
+ '[contenteditable="true"]',
53
+ '[role="combobox"]',
54
+ '[role="listbox"]',
55
+ '[role="spinbutton"]',
56
+ '[role="textbox"]'
57
+ ].join(',')
58
+
59
+ const fieldDescriptionSelector = '[data-ui-field-hint], [data-ui-field-error], .ui-field__hint, .ui-field__error'
60
+ const fieldErrorSelector = '[data-ui-field-error], .ui-field__error'
61
+
62
+ const formControlSelector = [
63
+ 'input:not([type="hidden"])',
64
+ 'select',
65
+ 'textarea'
66
+ ].join(',')
67
+
68
+ const validityMessageAttributes = [
69
+ ['valueMissing', 'data-error-required'],
70
+ ['typeMismatch', 'data-error-type'],
71
+ ['patternMismatch', 'data-error-pattern'],
72
+ ['tooShort', 'data-error-too-short'],
73
+ ['tooLong', 'data-error-too-long'],
74
+ ['rangeUnderflow', 'data-error-min'],
75
+ ['rangeOverflow', 'data-error-max'],
76
+ ['stepMismatch', 'data-error-step'],
77
+ ['badInput', 'data-error-bad-input'],
78
+ ['customError', 'data-error-custom']
79
+ ] as const
80
+
81
+ /**
82
+ * Top-layer mode: when the browser supports both the Popover API and CSS
83
+ * anchor positioning, floating panels are promoted to [popover] so they
84
+ * escape overflow clipping and get collision-aware placement via
85
+ * position-try-fallbacks. Other browsers keep the absolute fallback.
86
+ */
87
+ const supportsAnchoredPopovers =
88
+ typeof HTMLElement !== 'undefined' &&
89
+ 'showPopover' in HTMLElement.prototype &&
90
+ typeof CSS !== 'undefined' &&
91
+ CSS.supports('anchor-name: --ui-anchor') &&
92
+ CSS.supports('position-area: block-end')
93
+
94
+ let uiAnchorCount = 0
95
+ const nextAnchorName = (): string => `--ui-anchor-${++uiAnchorCount}`
96
+
97
+ const isElementVisible = (element: HTMLElement): boolean =>
98
+ typeof element.checkVisibility === 'function'
99
+ ? element.checkVisibility()
100
+ : element.offsetParent !== null
101
+
102
+ const getFocusable = (root: ParentNode): HTMLElement[] =>
103
+ [...root.querySelectorAll<HTMLElement>(focusableSelector)]
104
+ .filter(element => !element.hasAttribute('hidden') && isElementVisible(element))
105
+
106
+ const getPanel = (trigger: HTMLElement): HTMLElement | null => {
107
+ const controls = trigger.getAttribute('aria-controls')
108
+
109
+ if (controls) {
110
+ return document.getElementById(controls)
111
+ }
112
+
113
+ return trigger.nextElementSibling instanceof HTMLElement ? trigger.nextElementSibling : null
114
+ }
115
+
116
+ const getLoopedIndex = (
117
+ key: string,
118
+ currentIndex: number,
119
+ itemCount: number,
120
+ forwardKeys: readonly string[]
121
+ ): number => {
122
+ const lastIndex = itemCount - 1
123
+
124
+ if (key === 'Home') return 0
125
+
126
+ if (key === 'End') return lastIndex
127
+
128
+ if (forwardKeys.includes(key)) return (currentIndex + 1) % itemCount
129
+
130
+ return (currentIndex - 1 + itemCount) % itemCount
131
+ }
132
+
133
+ const setOpen = (trigger: HTMLElement, panel: HTMLElement, open: boolean): void => {
134
+ trigger.setAttribute('aria-expanded', String(open))
135
+
136
+ if (panel.hasAttribute('popover')) {
137
+ try {
138
+ if (open) {
139
+ panel.hidden = false
140
+
141
+ panel.showPopover()
142
+ } else {
143
+ panel.hidePopover()
144
+ }
145
+ } catch {
146
+ /* popover already in requested state */
147
+ }
148
+
149
+ panel.hidden = !open
150
+ } else {
151
+ panel.hidden = !open
152
+ }
153
+
154
+ panel.dataset.state = open ? 'open' : 'closed'
155
+ }
156
+
157
+ const closeDisclosure = (root: HTMLElement): void => {
158
+ const trigger = root.querySelector<HTMLElement>('[data-ui-trigger]')
159
+
160
+ if (!trigger) return
161
+
162
+ const panel = getPanel(trigger)
163
+
164
+ if (!panel) return
165
+
166
+ setOpen(trigger, panel, false)
167
+ }
168
+
169
+ const initDisclosures = (scope: ParentNode): void => {
170
+ for (const root of scope.querySelectorAll<HTMLElement>(disclosureSelector)) {
171
+ const trigger = root.querySelector<HTMLElement>('[data-ui-trigger]')
172
+
173
+ if (!trigger) continue
174
+
175
+ const panel = getPanel(trigger)
176
+
177
+ if (!panel || trigger.dataset.uiBound === 'true') continue
178
+
179
+ trigger.dataset.uiBound = 'true'
180
+
181
+ trigger.setAttribute('aria-haspopup', trigger.getAttribute('aria-haspopup') ?? 'menu')
182
+
183
+ if (supportsAnchoredPopovers) {
184
+ const anchorName = nextAnchorName()
185
+
186
+ panel.hidden = false
187
+
188
+ panel.setAttribute('popover', 'manual')
189
+
190
+ trigger.style.setProperty('anchor-name', anchorName)
191
+
192
+ panel.style.setProperty('position-anchor', anchorName)
193
+ }
194
+
195
+ setOpen(trigger, panel, trigger.getAttribute('aria-expanded') === 'true')
196
+
197
+ trigger.addEventListener('click', () => {
198
+ setOpen(trigger, panel, trigger.getAttribute('aria-expanded') !== 'true')
199
+ })
200
+
201
+ trigger.addEventListener('keydown', event => {
202
+ if (event.key !== 'ArrowDown' && event.key !== 'Enter' && event.key !== ' ') return
203
+
204
+ event.preventDefault()
205
+
206
+ setOpen(trigger, panel, true)
207
+
208
+ getFocusable(panel)[0]?.focus()
209
+ })
210
+
211
+ panel.addEventListener('keydown', event => {
212
+ if (event.key === 'Escape') {
213
+ event.preventDefault()
214
+
215
+ setOpen(trigger, panel, false)
216
+
217
+ trigger.focus({ preventScroll: true })
218
+
219
+ return
220
+ }
221
+
222
+ if (root.hasAttribute('data-ui-cascader')) return
223
+
224
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
225
+
226
+ const items = getFocusable(panel)
227
+
228
+ if (!items.length) return
229
+
230
+ event.preventDefault()
231
+
232
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement)
233
+ const nextItem = items[getLoopedIndex(event.key, Math.max(0, currentIndex), items.length, ['ArrowDown'])]
234
+
235
+ nextItem?.focus()
236
+ })
237
+
238
+ if (root.hasAttribute('data-ui-hover-card')) {
239
+ root.addEventListener('mouseenter', () => setOpen(trigger, panel, true))
240
+
241
+ root.addEventListener('mouseleave', () => setOpen(trigger, panel, false))
242
+ }
243
+ }
244
+ }
245
+
246
+ const initTooltips = (scope: ParentNode): void => {
247
+ for (const root of scope.querySelectorAll<HTMLElement>('.ui-tooltip, [data-ui-tooltip]')) {
248
+ if (root.dataset.uiTooltipBound === 'true') continue
249
+
250
+ const tip = root.querySelector<HTMLElement>('[role="tooltip"]')
251
+ const trigger = root.querySelector<HTMLElement>('[data-ui-tooltip-trigger], [data-ui-trigger], button, a[href], input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])')
252
+
253
+ if (!tip) continue
254
+
255
+ root.dataset.uiTooltipBound = 'true'
256
+
257
+ if (trigger && !trigger.closest('[role="tooltip"]')) {
258
+ if (!tip.id) {
259
+ tip.id = `ui-tooltip-${crypto.randomUUID()}`
260
+ }
261
+
262
+ const describedBy = new Set((trigger.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean))
263
+
264
+ describedBy.add(tip.id)
265
+
266
+ trigger.setAttribute('aria-describedby', [...describedBy].join(' '))
267
+ }
268
+
269
+ if (supportsAnchoredPopovers) {
270
+ const anchorName = nextAnchorName()
271
+
272
+ tip.setAttribute('popover', 'manual')
273
+
274
+ root.style.setProperty('anchor-name', anchorName)
275
+
276
+ tip.style.setProperty('position-anchor', anchorName)
277
+
278
+ let showTimer = 0
279
+
280
+ const show = (delay: number): void => {
281
+ window.clearTimeout(showTimer)
282
+
283
+ showTimer = window.setTimeout(() => {
284
+ try {
285
+ tip.showPopover()
286
+ } catch {
287
+ /* already open */
288
+ }
289
+ }, delay)
290
+ }
291
+
292
+ const hide = (): void => {
293
+ window.clearTimeout(showTimer)
294
+
295
+ try {
296
+ tip.hidePopover()
297
+ } catch {
298
+ /* already closed */
299
+ }
300
+ }
301
+
302
+ root.addEventListener('mouseenter', () => show(250))
303
+
304
+ root.addEventListener('mouseleave', hide)
305
+
306
+ root.addEventListener('focusin', () => show(0))
307
+
308
+ root.addEventListener('focusout', hide)
309
+
310
+ root.addEventListener('keydown', event => {
311
+ if (event.key === 'Escape') hide()
312
+ })
313
+ } else {
314
+ /* WCAG 1.4.13: hover/focus tooltips must be dismissible with Escape. */
315
+ root.addEventListener('keydown', event => {
316
+ if (event.key !== 'Escape') return
317
+
318
+ tip.style.visibility = 'hidden'
319
+ })
320
+
321
+ root.addEventListener('mouseleave', () => {
322
+ tip.style.removeProperty('visibility')
323
+ })
324
+
325
+ root.addEventListener('focusout', () => {
326
+ tip.style.removeProperty('visibility')
327
+ })
328
+ }
329
+ }
330
+ }
331
+
332
+ const initGlassTracking = (scope: ParentNode): void => {
333
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
334
+
335
+ for (const element of scope.querySelectorAll<HTMLElement>('[data-ui-glass-track]')) {
336
+ if (element.dataset.uiGlassBound === 'true') continue
337
+
338
+ element.dataset.uiGlassBound = 'true'
339
+
340
+ element.addEventListener('pointermove', event => {
341
+ const rect = element.getBoundingClientRect()
342
+
343
+ if (!rect.width || !rect.height) return
344
+
345
+ element.style.setProperty('--glass-x', `${(((event.clientX - rect.left) / rect.width) * 100).toFixed(1)}%`)
346
+
347
+ element.style.setProperty('--glass-y', `${(((event.clientY - rect.top) / rect.height) * 100).toFixed(1)}%`)
348
+ })
349
+
350
+ element.addEventListener('pointerleave', () => {
351
+ element.style.removeProperty('--glass-x')
352
+
353
+ element.style.removeProperty('--glass-y')
354
+ })
355
+ }
356
+ }
357
+
358
+ const initDialogs = (scope: ParentNode): void => {
359
+ for (const dialog of scope.querySelectorAll<HTMLDialogElement>('[data-ui-dialog], [data-ui-alert-dialog], [data-ui-drawer], [data-ui-sheet]')) {
360
+ if (dialog.dataset.uiBound === 'true') continue
361
+
362
+ dialog.dataset.uiBound = 'true'
363
+
364
+ dialog.setAttribute('aria-modal', 'true')
365
+
366
+ dialog.setAttribute('role', dialog.hasAttribute('data-ui-alert-dialog') ? 'alertdialog' : 'dialog')
367
+
368
+ dialog.addEventListener('click', event => {
369
+ if (event.target === dialog && !dialog.hasAttribute('data-ui-alert-dialog')) {
370
+ dialog.close()
371
+ }
372
+ })
373
+
374
+ dialog.addEventListener('close', () => {
375
+ const triggerId = dialog.dataset.uiLastTrigger
376
+ const trigger = triggerId ? document.getElementById(triggerId) : null
377
+
378
+ if (trigger instanceof HTMLElement) {
379
+ trigger.focus({ preventScroll: true })
380
+ }
381
+ })
382
+ }
383
+
384
+ for (const trigger of scope.querySelectorAll<HTMLElement>('[data-ui-dialog-trigger], [data-ui-alert-dialog-trigger], [data-ui-drawer-trigger], [data-ui-sheet-trigger]')) {
385
+ if (trigger.dataset.uiBound === 'true') continue
386
+
387
+ trigger.dataset.uiBound = 'true'
388
+
389
+ trigger.setAttribute('aria-haspopup', 'dialog')
390
+
391
+ trigger.addEventListener('click', () => {
392
+ const targetId =
393
+ trigger.dataset.uiDialogTrigger ??
394
+ trigger.dataset.uiAlertDialogTrigger ??
395
+ trigger.dataset.uiDrawerTrigger ??
396
+ trigger.dataset.uiSheetTrigger
397
+
398
+ if (!targetId) return
399
+
400
+ const dialog = document.getElementById(targetId)
401
+
402
+ if (!(dialog instanceof HTMLDialogElement)) return
403
+
404
+ if (!trigger.id) {
405
+ trigger.id = `ui-trigger-${crypto.randomUUID()}`
406
+ }
407
+
408
+ dialog.dataset.uiLastTrigger = trigger.id
409
+
410
+ dialog.showModal()
411
+
412
+ getFocusable(dialog)[0]?.focus({ preventScroll: true })
413
+ })
414
+ }
415
+
416
+ for (const closeButton of scope.querySelectorAll<HTMLElement>('[data-ui-dialog-close], [data-ui-alert-dialog-close], [data-ui-drawer-close], [data-ui-sheet-close]')) {
417
+ if (closeButton.dataset.uiBound === 'true') continue
418
+
419
+ closeButton.dataset.uiBound = 'true'
420
+
421
+ closeButton.addEventListener('click', () => {
422
+ closeButton.closest<HTMLDialogElement>('dialog')?.close()
423
+ })
424
+ }
425
+ }
426
+
427
+ const getFieldControl = (root: HTMLElement): HTMLElement | null => {
428
+ const controlId = root.dataset.uiFieldControl
429
+
430
+ if (controlId) {
431
+ const control = document.getElementById(controlId)
432
+
433
+ if (control instanceof HTMLElement) return control
434
+ }
435
+
436
+ return root.querySelector<HTMLElement>(fieldControlSelector)
437
+ }
438
+
439
+ const getFieldDescriptionId = (element: HTMLElement): string => {
440
+ if (!element.id) {
441
+ element.id = `ui-field-description-${crypto.randomUUID()}`
442
+ }
443
+
444
+ return element.id
445
+ }
446
+
447
+ const getFieldRoot = (control: HTMLElement): HTMLElement | null =>
448
+ control.closest<HTMLElement>('.ui-field, [data-ui-field]')
449
+
450
+ const isNativeFormControl = (element: EventTarget | null): element is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
451
+ element instanceof HTMLInputElement ||
452
+ element instanceof HTMLSelectElement ||
453
+ element instanceof HTMLTextAreaElement
454
+
455
+ const getValidationMessage = (
456
+ control: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
457
+ field: HTMLElement | null
458
+ ): string => {
459
+ const validity = control.validity
460
+
461
+ for (const [validityKey, attributeName] of validityMessageAttributes) {
462
+ if (!validity[validityKey]) continue
463
+
464
+ return control.getAttribute(attributeName) ??
465
+ field?.getAttribute(attributeName) ??
466
+ control.validationMessage
467
+ }
468
+
469
+ return control.validationMessage
470
+ }
471
+
472
+ const setFieldValidity = (
473
+ control: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
474
+ invalid: boolean,
475
+ message = ''
476
+ ): void => {
477
+ const field = getFieldRoot(control)
478
+
479
+ if (invalid) {
480
+ control.setAttribute('aria-invalid', 'true')
481
+
482
+ control.dataset.uiValidationInvalid = 'true'
483
+ } else if (control.dataset.uiValidationInvalid === 'true') {
484
+ control.removeAttribute('aria-invalid')
485
+
486
+ delete control.dataset.uiValidationInvalid
487
+ }
488
+
489
+ if (!field) return
490
+
491
+ field.dataset.invalid = String(invalid)
492
+
493
+ for (const error of field.querySelectorAll<HTMLElement>(fieldErrorSelector)) {
494
+ error.dataset.uiValidationErrorBound = 'true'
495
+
496
+ error.hidden = !invalid
497
+
498
+ error.textContent = invalid ? message : ''
499
+ }
500
+ }
501
+
502
+ const validateControl = (
503
+ control: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
504
+ form: HTMLFormElement
505
+ ): boolean => {
506
+ form.dispatchEvent(new CustomEvent('ui:validate', {
507
+ bubbles: true,
508
+ detail: { control, form, value: control.value }
509
+ }))
510
+
511
+ const invalid = !control.validity.valid
512
+ const message = invalid ? getValidationMessage(control, getFieldRoot(control)) : ''
513
+
514
+ setFieldValidity(control, invalid, message)
515
+
516
+ return !invalid
517
+ }
518
+
519
+ const getFormControls = (form: HTMLFormElement): Array<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> =>
520
+ [...form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(formControlSelector)]
521
+ .filter(control => control.form === form && !control.disabled)
522
+
523
+ const validateForm = (form: HTMLFormElement): Array<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> => {
524
+ const invalidControls: Array<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = []
525
+
526
+ for (const control of getFormControls(form)) {
527
+ if (!validateControl(control, form)) {
528
+ invalidControls.push(control)
529
+ }
530
+ }
531
+
532
+ return invalidControls
533
+ }
534
+
535
+ const initFields = (scope: ParentNode): void => {
536
+ for (const root of scope.querySelectorAll<HTMLElement>('.ui-field, [data-ui-field]')) {
537
+ if (root.dataset.uiFieldBound === 'true') continue
538
+
539
+ const control = getFieldControl(root)
540
+
541
+ if (!control) continue
542
+
543
+ root.dataset.uiFieldBound = 'true'
544
+
545
+ const existingIds = control.getAttribute('aria-describedby')?.trim().split(/\s+/) ?? []
546
+ const configuredIds = root.dataset.uiFieldDescribedby?.trim().split(/\s+/) ?? []
547
+
548
+ const descriptionIds = [...root.querySelectorAll<HTMLElement>(fieldDescriptionSelector)]
549
+ .map(element => getFieldDescriptionId(element))
550
+
551
+ const describedBy = [...new Set([...existingIds, ...configuredIds, ...descriptionIds].filter(Boolean))]
552
+
553
+ if (describedBy.length) {
554
+ control.setAttribute('aria-describedby', describedBy.join(' '))
555
+ }
556
+ }
557
+ }
558
+
559
+ const initForms = (scope: ParentNode): void => {
560
+ for (const form of scope.querySelectorAll<HTMLFormElement>('form[data-ui-form]')) {
561
+ if (form.dataset.uiFormBound === 'true') continue
562
+
563
+ form.dataset.uiFormBound = 'true'
564
+
565
+ form.noValidate = true
566
+
567
+ form.addEventListener('focusout', event => {
568
+ if (!isNativeFormControl(event.target) || event.target.form !== form) return
569
+
570
+ const valid = validateControl(event.target, form)
571
+
572
+ form.dispatchEvent(new CustomEvent(valid ? 'ui:valid' : 'ui:invalid', {
573
+ bubbles: true,
574
+ detail: valid
575
+ ? { control: event.target, form }
576
+ : { control: event.target, controls: [event.target], form }
577
+ }))
578
+ })
579
+
580
+ form.addEventListener('submit', event => {
581
+ const invalidControls = validateForm(form)
582
+
583
+ if (invalidControls.length) {
584
+ event.preventDefault()
585
+
586
+ const firstInvalid = invalidControls[0]
587
+
588
+ form.dispatchEvent(new CustomEvent('ui:invalid', {
589
+ bubbles: true,
590
+ detail: { control: firstInvalid, controls: invalidControls, form }
591
+ }))
592
+
593
+ firstInvalid?.focus({ preventScroll: true })
594
+
595
+ firstInvalid?.reportValidity()
596
+
597
+ return
598
+ }
599
+
600
+ form.dispatchEvent(new CustomEvent('ui:valid', {
601
+ bubbles: true,
602
+ detail: { controls: getFormControls(form), form }
603
+ }))
604
+ })
605
+ }
606
+ }
607
+
608
+ const initTabs = (scope: ParentNode): void => {
609
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-tabs]')) {
610
+ if (root.dataset.uiBound === 'true') continue
611
+
612
+ root.dataset.uiBound = 'true'
613
+
614
+ const tabs = [...root.querySelectorAll<HTMLElement>('[role="tab"]')]
615
+ const panels = [...root.querySelectorAll<HTMLElement>('[role="tabpanel"]')]
616
+
617
+ if (!tabs.length || !panels.length) continue
618
+
619
+ const activate = (tab: HTMLElement): void => {
620
+ for (const item of tabs) {
621
+ const selected = item === tab
622
+ const panelId = item.getAttribute('aria-controls')
623
+ const panel = panelId ? document.getElementById(panelId) : null
624
+
625
+ item.setAttribute('aria-selected', String(selected))
626
+
627
+ item.tabIndex = selected ? 0 : -1
628
+
629
+ if (panel) {
630
+ panel.hidden = !selected
631
+ }
632
+ }
633
+ }
634
+
635
+ const initiallySelected = tabs.find(tab => tab.getAttribute('aria-selected') === 'true') ?? tabs[0]
636
+
637
+ if (initiallySelected) activate(initiallySelected)
638
+
639
+ for (const [index, tab] of tabs.entries()) {
640
+ tab.addEventListener('click', () => activate(tab))
641
+
642
+ tab.addEventListener('keydown', event => {
643
+ const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']
644
+
645
+ if (!keys.includes(event.key)) return
646
+
647
+ event.preventDefault()
648
+
649
+ const nextTab = tabs[getLoopedIndex(event.key, index, tabs.length, ['ArrowRight'])]
650
+
651
+ if (nextTab) {
652
+ activate(nextTab)
653
+
654
+ nextTab.focus()
655
+ }
656
+ })
657
+ }
658
+ }
659
+ }
660
+
661
+ const initRovingGroups = (scope: ParentNode): void => {
662
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-menubar], [data-ui-navigation-menu], [data-ui-radio-group], [data-ui-toggle-group], [data-ui-toolbar]')) {
663
+ if (root.dataset.uiBound === 'true') continue
664
+
665
+ root.dataset.uiBound = 'true'
666
+
667
+ const items = [...root.querySelectorAll<HTMLElement>('button, a[href], input[type="radio"], [role="menuitem"], [role="radio"]')]
668
+
669
+ if (!items.length) continue
670
+
671
+ for (const [index, item] of items.entries()) {
672
+ item.tabIndex = index === 0 ? 0 : -1
673
+
674
+ item.addEventListener('keydown', event => {
675
+ const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']
676
+
677
+ if (!keys.includes(event.key)) return
678
+
679
+ event.preventDefault()
680
+
681
+ for (const nextItem of items) {
682
+ nextItem.tabIndex = -1
683
+ }
684
+
685
+ const nextItem = items[getLoopedIndex(event.key, index, items.length, ['ArrowRight', 'ArrowDown'])]
686
+
687
+ if (nextItem) {
688
+ nextItem.tabIndex = 0
689
+
690
+ nextItem.focus()
691
+
692
+ if (nextItem instanceof HTMLInputElement && nextItem.type === 'radio') {
693
+ nextItem.checked = true
694
+
695
+ nextItem.dispatchEvent(new Event('input', { bubbles: true }))
696
+
697
+ nextItem.dispatchEvent(new Event('change', { bubbles: true }))
698
+ } else if (nextItem.getAttribute('role') === 'radio') {
699
+ for (const radio of items) {
700
+ if (radio.getAttribute('role') === 'radio') {
701
+ radio.setAttribute('aria-checked', String(radio === nextItem))
702
+ }
703
+ }
704
+
705
+ nextItem.dispatchEvent(new Event('change', { bubbles: true }))
706
+ }
707
+ }
708
+ })
709
+ }
710
+ }
711
+ }
712
+
713
+ const initToggles = (scope: ParentNode): void => {
714
+ for (const toggle of scope.querySelectorAll<HTMLButtonElement>('[data-ui-toggle]')) {
715
+ if (toggle.dataset.uiBound === 'true') continue
716
+
717
+ toggle.dataset.uiBound = 'true'
718
+
719
+ toggle.addEventListener('click', () => {
720
+ const pressed = toggle.getAttribute('aria-pressed') !== 'true'
721
+
722
+ toggle.setAttribute('aria-pressed', String(pressed))
723
+
724
+ toggle.classList.toggle('ui-toggle--pressed', pressed)
725
+ })
726
+ }
727
+ }
728
+
729
+ const getVisibleItems = (items: HTMLElement[]): HTMLElement[] =>
730
+ items.filter(item => !item.hidden && item.offsetParent !== null && !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true')
731
+
732
+ const closeCombobox = (root: HTMLElement): void => {
733
+ const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
734
+ const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
735
+
736
+ if (!input || !listbox) return
737
+
738
+ input.setAttribute('aria-expanded', 'false')
739
+
740
+ listbox.hidden = true
741
+
742
+ listbox.dataset.state = 'closed'
743
+ }
744
+
745
+ const openCombobox = (root: HTMLElement): void => {
746
+ const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
747
+ const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
748
+
749
+ if (!input || !listbox) return
750
+
751
+ input.setAttribute('aria-expanded', 'true')
752
+
753
+ listbox.hidden = false
754
+
755
+ listbox.dataset.state = 'open'
756
+ }
757
+
758
+ let codeCopyLiveRegion: HTMLElement | undefined
759
+
760
+ const getCodeCopyLiveRegion = (): HTMLElement => {
761
+ if (codeCopyLiveRegion) return codeCopyLiveRegion
762
+
763
+ codeCopyLiveRegion = document.createElement('div')
764
+
765
+ codeCopyLiveRegion.setAttribute('aria-atomic', 'true')
766
+
767
+ codeCopyLiveRegion.setAttribute('aria-live', 'polite')
768
+
769
+ codeCopyLiveRegion.style.position = 'absolute'
770
+
771
+ codeCopyLiveRegion.style.width = '1px'
772
+
773
+ codeCopyLiveRegion.style.height = '1px'
774
+
775
+ codeCopyLiveRegion.style.overflow = 'hidden'
776
+
777
+ codeCopyLiveRegion.style.clipPath = 'inset(50%)'
778
+
779
+ codeCopyLiveRegion.style.whiteSpace = 'nowrap'
780
+
781
+ document.body.append(codeCopyLiveRegion)
782
+
783
+ return codeCopyLiveRegion
784
+ }
785
+
786
+ const initCodeCopy = (scope: ParentNode): void => {
787
+ for (const button of scope.querySelectorAll<HTMLButtonElement>('[data-ui-code-copy]')) {
788
+ if (button.dataset.uiCodeCopyBound === 'true') continue
789
+
790
+ button.dataset.uiCodeCopyBound = 'true'
791
+
792
+ button.addEventListener('click', async () => {
793
+ const root = button.closest<HTMLElement>('[data-ui-code]')
794
+ const code = root?.querySelector<HTMLElement>('pre code, code')
795
+ const text = code?.innerText ?? code?.textContent
796
+
797
+ if (!text || !navigator.clipboard) return
798
+
799
+ try {
800
+ await navigator.clipboard.writeText(text)
801
+ } catch {
802
+ return
803
+ }
804
+
805
+ button.classList.add('ui-code__copy--copied')
806
+
807
+ button.setAttribute('aria-label', 'Code copied!')
808
+
809
+ getCodeCopyLiveRegion().textContent = 'Code copied to clipboard'
810
+
811
+ window.setTimeout(() => {
812
+ button.classList.remove('ui-code__copy--copied')
813
+
814
+ button.setAttribute('aria-label', 'Copy code to clipboard')
815
+
816
+ getCodeCopyLiveRegion().textContent = ''
817
+ }, 2000)
818
+ })
819
+ }
820
+ }
821
+
822
+ const filterItems = (input: HTMLInputElement, items: HTMLElement[]): void => {
823
+ const query = input.value.trim().toLowerCase()
824
+
825
+ for (const item of items) {
826
+ const text = item.textContent?.toLowerCase() ?? ''
827
+
828
+ item.hidden = Boolean(query) && !text.includes(query)
829
+ }
830
+ }
831
+
832
+ const selectComboboxOption = (root: HTMLElement, option: HTMLElement): void => {
833
+ const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
834
+
835
+ if (!input) return
836
+
837
+ input.value = option.dataset.value ?? option.textContent?.trim() ?? ''
838
+
839
+ input.dispatchEvent(new Event('input', { bubbles: true }))
840
+
841
+ input.dispatchEvent(new Event('change', { bubbles: true }))
842
+
843
+ closeCombobox(root)
844
+
845
+ input.focus({ preventScroll: true })
846
+ }
847
+
848
+ const closeSelect = (root: HTMLElement): void => {
849
+ const trigger = root.querySelector<HTMLElement>('[data-ui-select-trigger]')
850
+ const listbox = root.querySelector<HTMLElement>('[data-ui-select-list]')
851
+
852
+ if (!trigger || !listbox) return
853
+
854
+ trigger.setAttribute('aria-expanded', 'false')
855
+
856
+ listbox.hidden = true
857
+
858
+ listbox.dataset.state = 'closed'
859
+ }
860
+
861
+ const openSelect = (root: HTMLElement): void => {
862
+ const trigger = root.querySelector<HTMLElement>('[data-ui-select-trigger]')
863
+ const listbox = root.querySelector<HTMLElement>('[data-ui-select-list]')
864
+
865
+ if (!trigger || !listbox) return
866
+
867
+ trigger.setAttribute('aria-expanded', 'true')
868
+
869
+ listbox.hidden = false
870
+
871
+ listbox.dataset.state = 'open'
872
+ }
873
+
874
+ const getSelectItems = (root: HTMLElement): HTMLElement[] =>
875
+ [...root.querySelectorAll<HTMLElement>('[data-ui-select-option]')]
876
+
877
+ const getEnabledSelectItems = (root: HTMLElement): HTMLElement[] =>
878
+ getSelectItems(root).filter(item => !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true')
879
+
880
+ const getSelectedSelectItem = (root: HTMLElement): HTMLElement | undefined =>
881
+ getEnabledSelectItems(root).find(item => item.getAttribute('aria-selected') === 'true')
882
+
883
+ const isPrintableKey = (event: KeyboardEvent): boolean =>
884
+ event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey
885
+
886
+ const renderSelectOptions = (select: HTMLSelectElement, listbox: HTMLElement): void => {
887
+ const items = [...select.options]
888
+ .filter(option => !option.hasAttribute('data-ui-select-placeholder'))
889
+ .map(option => {
890
+ const item = document.createElement('button')
891
+
892
+ item.type = 'button'
893
+
894
+ item.role = 'option'
895
+
896
+ item.dataset.uiSelectOption = ''
897
+
898
+ item.dataset.value = option.value
899
+
900
+ item.textContent = option.label
901
+
902
+ if (option.disabled) {
903
+ item.disabled = true
904
+
905
+ item.setAttribute('aria-disabled', 'true')
906
+ }
907
+
908
+ return item
909
+ })
910
+
911
+ listbox.replaceChildren(...items)
912
+ }
913
+
914
+ const syncSelectValue = (root: HTMLElement): void => {
915
+ const select = root.querySelector<HTMLSelectElement>('[data-ui-select-native]')
916
+ const trigger = root.querySelector<HTMLElement>('[data-ui-select-trigger]')
917
+ const value = root.querySelector<HTMLElement>('[data-ui-select-value]')
918
+
919
+ if (!select || !trigger || !value) return
920
+
921
+ const selectedOption = select.selectedOptions[0]
922
+ const hasSelection = Boolean(selectedOption && !selectedOption.hasAttribute('data-ui-select-placeholder'))
923
+ const placeholder = select.querySelector<HTMLOptionElement>('[data-ui-select-placeholder]')
924
+
925
+ value.textContent = hasSelection
926
+ ? selectedOption.label
927
+ : placeholder?.textContent?.trim() || 'Select an option'
928
+
929
+ root.dataset.placeholder = hasSelection ? 'false' : 'true'
930
+
931
+ for (const item of getSelectItems(root)) {
932
+ item.setAttribute('aria-selected', String(hasSelection && item.dataset.value === select.value))
933
+ }
934
+ }
935
+
936
+ const selectSelectOption = (root: HTMLElement, item: HTMLElement): void => {
937
+ const select = root.querySelector<HTMLSelectElement>('[data-ui-select-native]')
938
+ const trigger = root.querySelector<HTMLElement>('[data-ui-select-trigger]')
939
+
940
+ if (!select || !trigger || item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true') return
941
+
942
+ select.value = item.dataset.value ?? ''
943
+
944
+ select.dispatchEvent(new Event('input', { bubbles: true }))
945
+
946
+ select.dispatchEvent(new Event('change', { bubbles: true }))
947
+
948
+ syncSelectValue(root)
949
+
950
+ closeSelect(root)
951
+
952
+ trigger.focus({ preventScroll: true })
953
+ }
954
+
955
+ const focusSelectOption = (
956
+ root: HTMLElement,
957
+ key: string,
958
+ currentItem?: HTMLElement
959
+ ): void => {
960
+ const items = getEnabledSelectItems(root)
961
+
962
+ if (!items.length) return
963
+
964
+ const current = currentItem ?? getSelectedSelectItem(root) ?? items[0]
965
+ const currentIndex = items.indexOf(current)
966
+ const nextItem = items[getLoopedIndex(key, Math.max(0, currentIndex), items.length, ['ArrowDown'])]
967
+
968
+ nextItem?.focus()
969
+ }
970
+
971
+ const focusLoopedOption = (
972
+ items: HTMLElement[],
973
+ currentItem: HTMLElement,
974
+ key: string
975
+ ): void => {
976
+ const visibleItems = getVisibleItems(items)
977
+
978
+ if (!visibleItems.length) return
979
+
980
+ const currentIndex = visibleItems.indexOf(currentItem)
981
+ const nextItem = visibleItems[getLoopedIndex(key, Math.max(0, currentIndex), visibleItems.length, ['ArrowDown'])]
982
+
983
+ nextItem?.focus()
984
+ }
985
+
986
+ const initCommand = (scope: ParentNode): void => {
987
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-command]')) {
988
+ if (root.dataset.uiBound === 'true') continue
989
+
990
+ const input = root.querySelector<HTMLInputElement>('input[type="search"], input[type="text"], input:not([type])')
991
+
992
+ if (!input) continue
993
+
994
+ root.dataset.uiBound = 'true'
995
+
996
+ const items = [...root.querySelectorAll<HTMLElement>('[data-ui-command-item], [role="option"], option')]
997
+
998
+ input.addEventListener('input', () => {
999
+ filterItems(input, items)
1000
+ })
1001
+ }
1002
+ }
1003
+
1004
+ const initComboboxes = (scope: ParentNode): void => {
1005
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
1006
+ if (root.dataset.uiBound === 'true') continue
1007
+
1008
+ const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
1009
+ const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
1010
+
1011
+ if (!input || !listbox) continue
1012
+
1013
+ root.dataset.uiBound = 'true'
1014
+
1015
+ const items = [...listbox.querySelectorAll<HTMLElement>('[role="option"]')]
1016
+
1017
+ closeCombobox(root)
1018
+
1019
+ input.addEventListener('focus', () => openCombobox(root))
1020
+
1021
+ input.addEventListener('input', () => {
1022
+ filterItems(input, items)
1023
+
1024
+ openCombobox(root)
1025
+ })
1026
+
1027
+ input.addEventListener('keydown', event => {
1028
+ if (event.key === 'Escape') {
1029
+ closeCombobox(root)
1030
+
1031
+ return
1032
+ }
1033
+
1034
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return
1035
+
1036
+ const visibleItems = getVisibleItems(items)
1037
+
1038
+ if (!visibleItems.length) return
1039
+
1040
+ event.preventDefault()
1041
+
1042
+ openCombobox(root)
1043
+
1044
+ if (event.key === 'Enter') {
1045
+ selectComboboxOption(root, visibleItems[0])
1046
+
1047
+ return
1048
+ }
1049
+
1050
+ const targetIndex = event.key === 'ArrowUp' ? visibleItems.length - 1 : 0
1051
+
1052
+ visibleItems[targetIndex]?.focus()
1053
+ })
1054
+
1055
+ for (const item of items) {
1056
+ item.tabIndex = -1
1057
+
1058
+ item.addEventListener('click', () => selectComboboxOption(root, item))
1059
+
1060
+ item.addEventListener('keydown', event => {
1061
+ if (event.key === 'Escape') {
1062
+ closeCombobox(root)
1063
+
1064
+ input.focus({ preventScroll: true })
1065
+
1066
+ return
1067
+ }
1068
+
1069
+ if (event.key === 'Enter' || event.key === ' ') {
1070
+ event.preventDefault()
1071
+
1072
+ selectComboboxOption(root, item)
1073
+
1074
+ return
1075
+ }
1076
+
1077
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
1078
+
1079
+ event.preventDefault()
1080
+
1081
+ focusLoopedOption(items, item, event.key)
1082
+ })
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ const initSelects = (scope: ParentNode): void => {
1088
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-select]')) {
1089
+ if (root.dataset.uiBound === 'true') continue
1090
+
1091
+ const select = root.querySelector<HTMLSelectElement>('[data-ui-select-native]')
1092
+ const control = root.querySelector<HTMLElement>('[data-ui-select-control]')
1093
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-select-trigger]')
1094
+ const listbox = root.querySelector<HTMLElement>('[data-ui-select-list]')
1095
+
1096
+ if (!select || !control || !trigger || !listbox) continue
1097
+
1098
+ root.dataset.uiBound = 'true'
1099
+
1100
+ renderSelectOptions(select, listbox)
1101
+
1102
+ control.hidden = false
1103
+
1104
+ select.dataset.uiEnhanced = 'true'
1105
+
1106
+ select.tabIndex = -1
1107
+
1108
+ select.setAttribute('aria-hidden', 'true')
1109
+
1110
+ trigger.disabled = select.disabled
1111
+
1112
+ trigger.setAttribute('aria-haspopup', 'listbox')
1113
+
1114
+ for (const attribute of ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-invalid']) {
1115
+ const value = select.getAttribute(attribute)
1116
+
1117
+ if (value) {
1118
+ trigger.setAttribute(attribute, value)
1119
+ }
1120
+ }
1121
+
1122
+ if (select.required) {
1123
+ trigger.setAttribute('aria-required', 'true')
1124
+ }
1125
+
1126
+ if (!trigger.hasAttribute('aria-label') && !trigger.hasAttribute('aria-labelledby')) {
1127
+ const label = [...(select.labels ?? [])].find(item => item.textContent?.trim())
1128
+
1129
+ if (label?.textContent) {
1130
+ trigger.setAttribute('aria-label', label.textContent.trim())
1131
+ }
1132
+ }
1133
+
1134
+ for (const label of select.labels ?? []) {
1135
+ label.addEventListener('click', event => {
1136
+ event.preventDefault()
1137
+
1138
+ trigger.focus({ preventScroll: true })
1139
+ })
1140
+ }
1141
+
1142
+ let typeahead = ''
1143
+ let typeaheadTimer = 0
1144
+
1145
+ const focusTypeaheadOption = (currentItem?: HTMLElement): void => {
1146
+ const items = getEnabledSelectItems(root)
1147
+
1148
+ if (!items.length || !typeahead) return
1149
+
1150
+ const currentIndex = currentItem ? items.indexOf(currentItem) : items.indexOf(getSelectedSelectItem(root) ?? items[0])
1151
+ const orderedItems = [...items.slice(Math.max(0, currentIndex + 1)), ...items.slice(0, Math.max(0, currentIndex + 1))]
1152
+ const nextItem = orderedItems.find(item => item.textContent?.trim().toLowerCase().startsWith(typeahead))
1153
+
1154
+ nextItem?.focus()
1155
+ }
1156
+
1157
+ const handleTypeahead = (event: KeyboardEvent, currentItem?: HTMLElement): boolean => {
1158
+ if (!isPrintableKey(event)) return false
1159
+
1160
+ event.preventDefault()
1161
+
1162
+ typeahead += event.key.toLowerCase()
1163
+
1164
+ window.clearTimeout(typeaheadTimer)
1165
+
1166
+ typeaheadTimer = window.setTimeout(() => {
1167
+ typeahead = ''
1168
+ }, 700)
1169
+
1170
+ openSelect(root)
1171
+
1172
+ focusTypeaheadOption(currentItem)
1173
+
1174
+ return true
1175
+ }
1176
+
1177
+ syncSelectValue(root)
1178
+
1179
+ closeSelect(root)
1180
+
1181
+ trigger.addEventListener('click', () => {
1182
+ const isOpen = trigger.getAttribute('aria-expanded') === 'true'
1183
+
1184
+ if (isOpen) {
1185
+ closeSelect(root)
1186
+ } else {
1187
+ openSelect(root)
1188
+ }
1189
+ })
1190
+
1191
+ trigger.addEventListener('keydown', event => {
1192
+ if (handleTypeahead(event)) return
1193
+
1194
+ if (event.key === 'Escape') {
1195
+ closeSelect(root)
1196
+
1197
+ return
1198
+ }
1199
+
1200
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End' && event.key !== 'Enter' && event.key !== ' ') return
1201
+
1202
+ event.preventDefault()
1203
+
1204
+ openSelect(root)
1205
+
1206
+ if (event.key === 'Enter' || event.key === ' ') {
1207
+ ;
1208
+
1209
+ (getSelectedSelectItem(root) ?? getEnabledSelectItems(root)[0])?.focus()
1210
+
1211
+ return
1212
+ }
1213
+
1214
+ focusSelectOption(root, event.key)
1215
+ })
1216
+
1217
+ select.addEventListener('change', () => syncSelectValue(root))
1218
+
1219
+ select.form?.addEventListener('reset', () => {
1220
+ window.setTimeout(() => syncSelectValue(root))
1221
+ })
1222
+
1223
+ for (const item of getSelectItems(root)) {
1224
+ item.tabIndex = -1
1225
+
1226
+ item.addEventListener('click', () => selectSelectOption(root, item))
1227
+
1228
+ item.addEventListener('keydown', event => {
1229
+ if (handleTypeahead(event, item)) return
1230
+
1231
+ if (event.key === 'Escape') {
1232
+ closeSelect(root)
1233
+
1234
+ trigger.focus({ preventScroll: true })
1235
+
1236
+ return
1237
+ }
1238
+
1239
+ if (event.key === 'Enter' || event.key === ' ') {
1240
+ event.preventDefault()
1241
+
1242
+ selectSelectOption(root, item)
1243
+
1244
+ return
1245
+ }
1246
+
1247
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
1248
+
1249
+ event.preventDefault()
1250
+
1251
+ focusSelectOption(root, event.key, item)
1252
+ })
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ const parseNumberList = (value: string | undefined, count: number, fallback: number): number[] => {
1258
+ const values = (value ?? '')
1259
+ .split(',')
1260
+ .map(item => Number.parseFloat(item.trim()))
1261
+ .filter(Number.isFinite)
1262
+
1263
+ return Array.from({ length: count }, (_, index) => values[index] ?? values[0] ?? fallback)
1264
+ }
1265
+
1266
+ const normalizeSizes = (sizes: number[], count: number): number[] => {
1267
+ const fallbackSize = 100 / Math.max(1, count)
1268
+
1269
+ const usableSizes = Array.from({ length: count }, (_, index) => {
1270
+ const size = sizes[index] ?? fallbackSize
1271
+
1272
+ return Number.isFinite(size) && size > 0 ? size : fallbackSize
1273
+ })
1274
+
1275
+ const total = usableSizes.reduce((sum, size) => sum + size, 0)
1276
+
1277
+ if (total <= 0) return Array.from({ length: count }, () => fallbackSize)
1278
+
1279
+ return usableSizes.map(size => (size / total) * 100)
1280
+ }
1281
+
1282
+ const initResizableGroups = (scope: ParentNode): void => {
1283
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-resizable]')) {
1284
+ if (root.dataset.uiBound === 'true') continue
1285
+
1286
+ const panes = [...root.children].filter((child): child is HTMLElement =>
1287
+ child instanceof HTMLElement &&
1288
+ !child.hasAttribute('data-ui-resizable-handle') &&
1289
+ !child.hasAttribute('data-ui-resizable-handle-template')
1290
+ )
1291
+
1292
+ if (panes.length < 2) continue
1293
+
1294
+ root.dataset.uiBound = 'true'
1295
+
1296
+ root.dataset.uiResizableEnhanced = 'true'
1297
+
1298
+ const direction = root.dataset.orientation === 'vertical' ? 'vertical' : 'horizontal'
1299
+ const separatorOrientation = direction === 'horizontal' ? 'vertical' : 'horizontal'
1300
+ const axis = direction === 'horizontal' ? 'clientX' : 'clientY'
1301
+ const sizeProperty = direction === 'horizontal' ? 'width' : 'height'
1302
+ const minSizes = parseNumberList(root.dataset.uiResizableMinSize, panes.length, 12)
1303
+ const maxSizes = parseNumberList(root.dataset.uiResizableMaxSize, panes.length, 88)
1304
+
1305
+ const initialSizes = normalizeSizes(
1306
+ parseNumberList(root.dataset.uiResizableDefaultSizes, panes.length, 100 / panes.length),
1307
+ panes.length
1308
+ )
1309
+
1310
+ const handleTemplate = root.querySelector<HTMLTemplateElement>(':scope > [data-ui-resizable-handle-template]')
1311
+ let sizes = [...initialSizes]
1312
+
1313
+ const applySizes = (): void => {
1314
+ for (const [index, pane] of panes.entries()) {
1315
+ pane.dataset.uiResizablePanel = ''
1316
+
1317
+ pane.style.setProperty('--ui-resizable-size', `${sizes[index]}%`)
1318
+ }
1319
+
1320
+ for (const handle of root.querySelectorAll<HTMLElement>('[data-ui-resizable-handle]')) {
1321
+ const index = Number.parseInt(handle.dataset.index ?? '0', 10)
1322
+
1323
+ handle.setAttribute('aria-valuemin', String(Math.round(minSizes[index] ?? 0)))
1324
+
1325
+ handle.setAttribute('aria-valuemax', String(Math.round(maxSizes[index] ?? 100)))
1326
+
1327
+ handle.setAttribute('aria-valuenow', String(Math.round(sizes[index] ?? 0)))
1328
+ }
1329
+ }
1330
+
1331
+ const resizePair = (index: number, nextSize: number): void => {
1332
+ const nextIndex = index + 1
1333
+ const total = sizes[index] + sizes[nextIndex]
1334
+ const min = Math.max(minSizes[index] ?? 0, total - (maxSizes[nextIndex] ?? 100))
1335
+ const max = Math.min(maxSizes[index] ?? 100, total - (minSizes[nextIndex] ?? 0))
1336
+ const paneSize = Math.min(max, Math.max(min, nextSize))
1337
+
1338
+ sizes[index] = paneSize
1339
+
1340
+ sizes[nextIndex] = total - paneSize
1341
+
1342
+ applySizes()
1343
+ }
1344
+
1345
+ for (const [index, pane] of panes.entries()) {
1346
+ pane.dataset.uiResizablePanel = ''
1347
+
1348
+ if (index >= panes.length - 1) continue
1349
+
1350
+ const handleFromTemplate = handleTemplate?.content
1351
+ .querySelector<HTMLButtonElement>('[data-ui-resizable-handle]')
1352
+ ?.cloneNode(true)
1353
+
1354
+ const handle = handleFromTemplate instanceof HTMLButtonElement
1355
+ ? handleFromTemplate
1356
+ : document.createElement('button')
1357
+
1358
+ handle.type = 'button'
1359
+
1360
+ handle.className = 'ui-resizable__handle'
1361
+
1362
+ handle.dataset.uiResizableHandle = ''
1363
+
1364
+ handle.dataset.index = String(index)
1365
+
1366
+ handle.tabIndex = 0
1367
+
1368
+ handle.setAttribute('aria-label', `Resize panel ${index + 1}`)
1369
+
1370
+ handle.setAttribute('role', 'separator')
1371
+
1372
+ handle.setAttribute('aria-orientation', separatorOrientation)
1373
+
1374
+ pane.after(handle)
1375
+
1376
+ let startPosition = 0
1377
+ let startSize = 0
1378
+ let containerSize = 1
1379
+
1380
+ handle.addEventListener('pointerdown', event => {
1381
+ if (event.button !== 0) return
1382
+
1383
+ event.preventDefault()
1384
+
1385
+ startPosition = event[axis]
1386
+
1387
+ startSize = sizes[index]
1388
+
1389
+ containerSize = Math.max(1, root.getBoundingClientRect()[sizeProperty])
1390
+
1391
+ root.dataset.resizing = 'true'
1392
+
1393
+ handle.dataset.active = 'true'
1394
+
1395
+ handle.setPointerCapture(event.pointerId)
1396
+ })
1397
+
1398
+ handle.addEventListener('pointermove', event => {
1399
+ if (handle.dataset.active !== 'true') return
1400
+
1401
+ const delta = ((event[axis] - startPosition) / containerSize) * 100
1402
+
1403
+ resizePair(index, startSize + delta)
1404
+ })
1405
+
1406
+ handle.addEventListener('pointerup', event => {
1407
+ if (handle.dataset.active !== 'true') return
1408
+
1409
+ delete handle.dataset.active
1410
+
1411
+ delete root.dataset.resizing
1412
+
1413
+ handle.releasePointerCapture(event.pointerId)
1414
+ })
1415
+
1416
+ handle.addEventListener('dblclick', () => {
1417
+ if (root.dataset.uiResizableReset !== 'true') return
1418
+
1419
+ sizes = [...initialSizes]
1420
+
1421
+ applySizes()
1422
+ })
1423
+
1424
+ handle.addEventListener('keydown', event => {
1425
+ const step = event.shiftKey ? 10 : 2
1426
+
1427
+ const keyDeltas: Record<string, number> = direction === 'horizontal'
1428
+ ? { ArrowLeft: -step, ArrowRight: step }
1429
+ : { ArrowDown: step, ArrowUp: -step }
1430
+
1431
+ if (event.key === 'Home') {
1432
+ event.preventDefault()
1433
+
1434
+ resizePair(index, minSizes[index] ?? 0)
1435
+
1436
+ return
1437
+ }
1438
+
1439
+ if (event.key === 'End') {
1440
+ event.preventDefault()
1441
+
1442
+ resizePair(index, maxSizes[index] ?? 100)
1443
+
1444
+ return
1445
+ }
1446
+
1447
+ const delta = keyDeltas[event.key]
1448
+
1449
+ if (delta === undefined) return
1450
+
1451
+ event.preventDefault()
1452
+
1453
+ resizePair(index, sizes[index] + delta)
1454
+ })
1455
+ }
1456
+
1457
+ applySizes()
1458
+ }
1459
+ }
1460
+
1461
+ const sanitizeOtpValue = (input: HTMLInputElement, value: string, length: number): string => {
1462
+ const numericOnly = input.inputMode === 'numeric' || input.getAttribute('pattern') === '[0-9]*'
1463
+ const normalized = numericOnly ? value.replaceAll(/\D/g, '') : value.replaceAll(/\s/g, '')
1464
+
1465
+ return normalized.slice(0, length)
1466
+ }
1467
+
1468
+ const initInputOtpFields = (scope: ParentNode): void => {
1469
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-input-otp]')) {
1470
+ if (root.dataset.uiBound === 'true') continue
1471
+
1472
+ const input = root.querySelector<HTMLInputElement>('[data-ui-input-otp-native]')
1473
+ const segments = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-input-otp-segment]')]
1474
+
1475
+ if (!input || segments.length === 0) continue
1476
+
1477
+ root.dataset.uiBound = 'true'
1478
+
1479
+ const length = Number.parseInt(root.dataset.uiInputOtpLength ?? '', 10) || segments.length
1480
+
1481
+ input.dataset.uiEnhanced = 'true'
1482
+
1483
+ const syncSegments = (): void => {
1484
+ const value = sanitizeOtpValue(input, input.value, length)
1485
+
1486
+ if (input.value !== value) {
1487
+ input.value = value
1488
+ }
1489
+
1490
+ const activeIndex = Math.min(length - 1, Math.max(0, input.selectionStart ?? value.length))
1491
+
1492
+ root.dataset.disabled = input.disabled ? 'true' : 'false'
1493
+
1494
+ root.dataset.invalid = input.getAttribute('aria-invalid') === 'true' ? 'true' : 'false'
1495
+
1496
+ for (const [index, segment] of segments.entries()) {
1497
+ const char = segment.querySelector<HTMLElement>('[data-ui-input-otp-char]')
1498
+
1499
+ segment.disabled = input.disabled
1500
+
1501
+ segment.dataset.active = String(document.activeElement === input && index === activeIndex)
1502
+
1503
+ if (char) {
1504
+ char.textContent = value[index] ?? '\u00a0'
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ const setSelection = (index: number): void => {
1510
+ const position = Math.min(input.value.length, Math.max(0, index))
1511
+
1512
+ input.focus({ preventScroll: true })
1513
+
1514
+ input.setSelectionRange(position, position)
1515
+
1516
+ syncSegments()
1517
+ }
1518
+
1519
+ const setValue = (value: string): void => {
1520
+ input.value = sanitizeOtpValue(input, value, length)
1521
+
1522
+ input.setSelectionRange(input.value.length, input.value.length)
1523
+
1524
+ input.dispatchEvent(new Event('input', { bubbles: true }))
1525
+
1526
+ input.dispatchEvent(new Event('change', { bubbles: true }))
1527
+
1528
+ syncSegments()
1529
+ }
1530
+
1531
+ const segmentsRoot = root.querySelector<HTMLElement>('[data-ui-input-otp-segments]')
1532
+
1533
+ if (segmentsRoot) {
1534
+ segmentsRoot.hidden = false
1535
+ }
1536
+
1537
+ syncSegments()
1538
+
1539
+ input.addEventListener('input', syncSegments)
1540
+
1541
+ input.addEventListener('change', syncSegments)
1542
+
1543
+ input.addEventListener('focus', syncSegments)
1544
+
1545
+ input.addEventListener('blur', syncSegments)
1546
+
1547
+ input.addEventListener('click', syncSegments)
1548
+
1549
+ input.addEventListener('keyup', syncSegments)
1550
+
1551
+ input.addEventListener('keydown', event => {
1552
+ if (event.key === 'ArrowLeft') {
1553
+ event.preventDefault()
1554
+
1555
+ setSelection((input.selectionStart ?? input.value.length) - 1)
1556
+
1557
+ return
1558
+ }
1559
+
1560
+ if (event.key === 'ArrowRight') {
1561
+ event.preventDefault()
1562
+
1563
+ setSelection((input.selectionStart ?? input.value.length) + 1)
1564
+
1565
+ return
1566
+ }
1567
+
1568
+ if (event.key === 'Home') {
1569
+ event.preventDefault()
1570
+
1571
+ setSelection(0)
1572
+
1573
+ return
1574
+ }
1575
+
1576
+ if (event.key === 'End') {
1577
+ event.preventDefault()
1578
+
1579
+ setSelection(input.value.length)
1580
+ }
1581
+ })
1582
+
1583
+ input.addEventListener('paste', event => {
1584
+ const pasted = event.clipboardData?.getData('text') ?? ''
1585
+
1586
+ if (!pasted) return
1587
+
1588
+ event.preventDefault()
1589
+
1590
+ setValue(pasted)
1591
+ })
1592
+
1593
+ input.form?.addEventListener('reset', () => {
1594
+ window.setTimeout(syncSegments)
1595
+ })
1596
+
1597
+ for (const [index, segment] of segments.entries()) {
1598
+ segment.addEventListener('click', () => setSelection(index))
1599
+ }
1600
+ }
1601
+ }
1602
+
1603
+ /* cspell:ignore Datatable datatable */
1604
+ /* eslint-disable @stylistic/padding-line-between-statements, no-nested-ternary, unicorn/no-array-for-each, unicorn/prefer-string-replace-all */
1605
+ const getDataTableSortValue = (cell: HTMLTableCellElement | undefined): string =>
1606
+ cell?.dataset.sortValue ?? cell?.textContent?.trim() ?? ''
1607
+
1608
+ const compareDataTableValues = (value: string, other: string, sortType: string | undefined): number => {
1609
+ const numericValue = Number(value.replace(/,/g, ''))
1610
+ const numericOther = Number(other.replace(/,/g, ''))
1611
+ const useNumeric = sortType === 'number' || (
1612
+ sortType !== 'string' &&
1613
+ value.trim() !== '' &&
1614
+ other.trim() !== '' &&
1615
+ Number.isFinite(numericValue) &&
1616
+ Number.isFinite(numericOther)
1617
+ )
1618
+
1619
+ if (useNumeric) {
1620
+ return numericValue - numericOther
1621
+ }
1622
+
1623
+ return value.localeCompare(other, undefined, { numeric: true, sensitivity: 'base' })
1624
+ }
1625
+
1626
+ const sortDataTableRows = (
1627
+ table: HTMLTableElement,
1628
+ header: HTMLTableCellElement,
1629
+ columnIndex: number,
1630
+ direction: 'ascending' | 'descending' | 'none'
1631
+ ): void => {
1632
+ const body = table.tBodies[0]
1633
+
1634
+ if (!body) return
1635
+
1636
+ const rows = [...body.rows]
1637
+ const sortType = header.dataset.uiDatatableSortType
1638
+
1639
+ rows.sort((row, other) => {
1640
+ if (direction === 'none') {
1641
+ return Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
1642
+ }
1643
+
1644
+ const result = compareDataTableValues(
1645
+ getDataTableSortValue(row.cells[columnIndex]),
1646
+ getDataTableSortValue(other.cells[columnIndex]),
1647
+ sortType
1648
+ )
1649
+
1650
+ return (direction === 'ascending' ? result : -result) ||
1651
+ Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
1652
+ })
1653
+
1654
+ rows.forEach(row => body.append(row))
1655
+ }
1656
+
1657
+ const updateDataTableSort = (
1658
+ root: HTMLElement,
1659
+ table: HTMLTableElement,
1660
+ header: HTMLTableCellElement,
1661
+ direction: 'ascending' | 'descending' | 'none'
1662
+ ): void => {
1663
+ for (const item of table.querySelectorAll<HTMLTableCellElement>('thead th[aria-sort]')) {
1664
+ item.setAttribute('aria-sort', item === header ? direction : 'none')
1665
+ item.dataset.sort = item === header ? direction : 'none'
1666
+ }
1667
+
1668
+ root.dataset.uiDatatableSortDirection = direction
1669
+
1670
+ if (direction === 'none') {
1671
+ delete root.dataset.uiDatatableSortColumn
1672
+ } else {
1673
+ root.dataset.uiDatatableSortColumn = header.dataset.uiDatatableColumn ?? ''
1674
+ }
1675
+ }
1676
+
1677
+ const initDataTableSorting = (root: HTMLElement, table: HTMLTableElement): void => {
1678
+ const headers = [...table.querySelectorAll<HTMLTableCellElement>('thead th')].filter(header =>
1679
+ header.dataset.uiDatatableSortable === 'true' ||
1680
+ header.dataset.sortable === 'true' ||
1681
+ header.hasAttribute('data-sortable')
1682
+ )
1683
+
1684
+ for (const header of headers) {
1685
+ if (header.dataset.uiDatatableSortBound === 'true') continue
1686
+
1687
+ const row = header.parentElement
1688
+ const columnIndex = row ? [...row.children].indexOf(header) : -1
1689
+
1690
+ if (columnIndex < 0) continue
1691
+
1692
+ header.dataset.uiDatatableSortBound = 'true'
1693
+ header.dataset.uiDatatableColumn = String(columnIndex)
1694
+ header.setAttribute('aria-sort', 'none')
1695
+
1696
+ const button = document.createElement('button')
1697
+
1698
+ button.type = 'button'
1699
+ button.className = 'ui-data-table__sort'
1700
+ button.dataset.uiDatatableSort = ''
1701
+
1702
+ while (header.firstChild) {
1703
+ button.append(header.firstChild)
1704
+ }
1705
+
1706
+ header.append(button)
1707
+
1708
+ button.addEventListener('click', () => {
1709
+ const currentColumn = root.dataset.uiDatatableSortColumn
1710
+ const currentDirection = header.getAttribute('aria-sort')
1711
+ const nextDirection = currentColumn !== String(columnIndex) || currentDirection === 'none'
1712
+ ? 'ascending'
1713
+ : currentDirection === 'ascending'
1714
+ ? 'descending'
1715
+ : 'none'
1716
+
1717
+ updateDataTableSort(root, table, header, nextDirection)
1718
+ sortDataTableRows(table, header, columnIndex, nextDirection)
1719
+ })
1720
+ }
1721
+
1722
+ const body = table.tBodies[0]
1723
+
1724
+ if (!body) return
1725
+
1726
+ ;[...body.rows].forEach((row, index) => {
1727
+ row.dataset.uiDatatableIndex = row.dataset.uiDatatableIndex ?? String(index)
1728
+ })
1729
+ }
1730
+
1731
+ const getDataTableRows = (table: HTMLTableElement): HTMLTableRowElement[] =>
1732
+ [...(table.tBodies[0]?.rows ?? [])]
1733
+
1734
+ const getDataTableRowValue = (row: HTMLTableRowElement, index: number): string =>
1735
+ row.dataset.value ?? row.id ?? String(index)
1736
+
1737
+ const syncDataTableSelection = (
1738
+ root: HTMLElement,
1739
+ table: HTMLTableElement,
1740
+ selectAll: HTMLInputElement,
1741
+ dispatch = false
1742
+ ): void => {
1743
+ const rows = getDataTableRows(table)
1744
+ const selectedValues: string[] = []
1745
+
1746
+ rows.forEach((row, index) => {
1747
+ const checkbox = row.querySelector<HTMLInputElement>('[data-ui-datatable-row-select]')
1748
+ const selected = checkbox?.checked === true
1749
+
1750
+ row.setAttribute('aria-selected', String(selected))
1751
+
1752
+ if (selected) {
1753
+ row.dataset.state = 'selected'
1754
+ selectedValues.push(getDataTableRowValue(row, index))
1755
+ } else {
1756
+ delete row.dataset.state
1757
+ }
1758
+ })
1759
+
1760
+ selectAll.checked = rows.length > 0 && selectedValues.length === rows.length
1761
+ selectAll.indeterminate = selectedValues.length > 0 && selectedValues.length < rows.length
1762
+
1763
+ const name = root.dataset.uiDatatableName
1764
+ let inputs = root.querySelector<HTMLElement>('[data-ui-datatable-inputs]')
1765
+
1766
+ if (name) {
1767
+ if (!inputs) {
1768
+ inputs = document.createElement('div')
1769
+ inputs.hidden = true
1770
+ inputs.dataset.uiDatatableInputs = ''
1771
+ root.append(inputs)
1772
+ }
1773
+
1774
+ inputs.replaceChildren(...selectedValues.map(value => {
1775
+ const input = document.createElement('input')
1776
+
1777
+ input.type = 'hidden'
1778
+ input.name = name
1779
+ input.value = value
1780
+
1781
+ return input
1782
+ }))
1783
+ } else {
1784
+ inputs?.replaceChildren()
1785
+ }
1786
+
1787
+ if (dispatch) {
1788
+ root.dispatchEvent(new CustomEvent('ui:datatable-selection-change', {
1789
+ bubbles: true,
1790
+ detail: { values: selectedValues }
1791
+ }))
1792
+ }
1793
+ }
1794
+
1795
+ const initDataTableSelection = (root: HTMLElement, table: HTMLTableElement): void => {
1796
+ if (root.dataset.uiDatatableSelectable !== 'true' || root.dataset.uiDatatableSelectionBound === 'true') return
1797
+
1798
+ const headRow = table.tHead?.rows[0]
1799
+ const body = table.tBodies[0]
1800
+
1801
+ if (!headRow || !body) return
1802
+
1803
+ root.dataset.uiDatatableSelectionBound = 'true'
1804
+
1805
+ const headerCell = document.createElement('th')
1806
+ const selectAll = document.createElement('input')
1807
+
1808
+ headerCell.className = 'ui-data-table__selection'
1809
+ headerCell.scope = 'col'
1810
+ selectAll.type = 'checkbox'
1811
+ selectAll.className = 'ui-data-table__checkbox'
1812
+ selectAll.dataset.uiDatatableSelectAll = ''
1813
+ selectAll.setAttribute('aria-label', 'Select all rows')
1814
+ headerCell.append(selectAll)
1815
+ headRow.prepend(headerCell)
1816
+
1817
+ getDataTableRows(table).forEach((row, index) => {
1818
+ row.dataset.uiDatatableRow = ''
1819
+ row.dataset.value = getDataTableRowValue(row, index)
1820
+
1821
+ const cell = document.createElement('td')
1822
+ const checkbox = document.createElement('input')
1823
+ const selected = row.dataset.state === 'selected' || row.getAttribute('aria-selected') === 'true'
1824
+
1825
+ cell.className = 'ui-data-table__selection'
1826
+ checkbox.type = 'checkbox'
1827
+ checkbox.className = 'ui-data-table__checkbox'
1828
+ checkbox.dataset.uiDatatableRowSelect = ''
1829
+ checkbox.checked = selected
1830
+ checkbox.defaultChecked = selected
1831
+ checkbox.setAttribute('aria-label', `Select row ${index + 1}`)
1832
+ checkbox.addEventListener('change', () => syncDataTableSelection(root, table, selectAll, true))
1833
+
1834
+ cell.append(checkbox)
1835
+ row.prepend(cell)
1836
+ })
1837
+
1838
+ selectAll.addEventListener('change', () => {
1839
+ for (const checkbox of table.querySelectorAll<HTMLInputElement>('[data-ui-datatable-row-select]')) {
1840
+ checkbox.checked = selectAll.checked
1841
+ }
1842
+
1843
+ syncDataTableSelection(root, table, selectAll, true)
1844
+ })
1845
+
1846
+ root.closest('form')?.addEventListener('reset', () => {
1847
+ window.setTimeout(() => syncDataTableSelection(root, table, selectAll, true))
1848
+ })
1849
+
1850
+ syncDataTableSelection(root, table, selectAll)
1851
+ }
1852
+
1853
+ const initDataTables = (scope: ParentNode): void => {
1854
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-datatable]')) {
1855
+ if (root.dataset.uiBound === 'true') continue
1856
+
1857
+ const table = root.querySelector<HTMLTableElement>('table')
1858
+
1859
+ if (!table) continue
1860
+
1861
+ root.dataset.uiBound = 'true'
1862
+
1863
+ initDataTableSelection(root, table)
1864
+ initDataTableSorting(root, table)
1865
+ }
1866
+ }
1867
+ /* eslint-enable @stylistic/padding-line-between-statements, no-nested-ternary, unicorn/no-array-for-each, unicorn/prefer-string-replace-all */
1868
+
1869
+ const calendarDatePattern = /^\d{4}-\d{2}-\d{2}$/
1870
+ const calendarMonthPattern = /^\d{4}-\d{2}$/
1871
+
1872
+ const parseCalendarDate = (value: string | null | undefined): Date | null => {
1873
+ if (!value || !calendarDatePattern.test(value)) return null
1874
+
1875
+ const [year, month, day] = value.split('-').map(Number)
1876
+ const date = new Date(Date.UTC(year, month - 1, day))
1877
+
1878
+ return date.getUTCFullYear() === year &&
1879
+ date.getUTCMonth() === month - 1 &&
1880
+ date.getUTCDate() === day
1881
+ ? date
1882
+ : null
1883
+ }
1884
+
1885
+ const parseCalendarMonth = (value: string | null | undefined): Date | null => {
1886
+ if (!value || !calendarMonthPattern.test(value)) return null
1887
+
1888
+ const [year, month] = value.split('-').map(Number)
1889
+ const date = new Date(Date.UTC(year, month - 1, 1))
1890
+
1891
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 ? date : null
1892
+ }
1893
+
1894
+ const formatCalendarDate = (date: Date): string => date.toISOString().slice(0, 10)
1895
+ const formatCalendarMonth = (date: Date): string => date.toISOString().slice(0, 7)
1896
+
1897
+ const addCalendarDays = (date: Date, days: number): Date =>
1898
+ new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days))
1899
+
1900
+ const getCalendarDaysInMonth = (date: Date): number =>
1901
+ new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate()
1902
+
1903
+ const addCalendarMonths = (date: Date, months: number): Date => {
1904
+ const year = date.getUTCFullYear()
1905
+ const month = date.getUTCMonth() + months
1906
+ const day = date.getUTCDate()
1907
+ const targetMonth = new Date(Date.UTC(year, month, 1))
1908
+ const daysInTargetMonth = getCalendarDaysInMonth(targetMonth)
1909
+
1910
+ return new Date(Date.UTC(
1911
+ targetMonth.getUTCFullYear(),
1912
+ targetMonth.getUTCMonth(),
1913
+ Math.min(day, daysInTargetMonth)
1914
+ ))
1915
+ }
1916
+
1917
+ const compareCalendarDates = (date: Date, other: Date | null): number => {
1918
+ if (!other) return 0
1919
+
1920
+ return formatCalendarDate(date).localeCompare(formatCalendarDate(other))
1921
+ }
1922
+
1923
+ const isCalendarDateDisabled = (
1924
+ root: HTMLElement,
1925
+ date: Date,
1926
+ min: Date | null,
1927
+ max: Date | null
1928
+ ): boolean =>
1929
+ root.dataset.disabled === 'true' ||
1930
+ compareCalendarDates(date, min) < 0 ||
1931
+ compareCalendarDates(date, max) > 0
1932
+
1933
+ const clampCalendarDate = (date: Date, min: Date | null, max: Date | null): Date => {
1934
+ if (min && compareCalendarDates(date, min) < 0) return min
1935
+
1936
+ if (max && compareCalendarDates(date, max) > 0) return max
1937
+
1938
+ return date
1939
+ }
1940
+
1941
+ const getCalendarLocale = (): string => document.documentElement.lang || navigator.language || 'en'
1942
+
1943
+ const getCalendarToday = (): Date => {
1944
+ const today = new Date()
1945
+
1946
+ return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()))
1947
+ }
1948
+
1949
+ const getCalendarGridStart = (month: Date): Date =>
1950
+ addCalendarDays(month, -((month.getUTCDay() + 6) % 7))
1951
+
1952
+ const getCalendarFocusDate = (
1953
+ root: HTMLElement,
1954
+ month: Date,
1955
+ min: Date | null,
1956
+ max: Date | null,
1957
+ requestedDate?: Date | null
1958
+ ): Date => {
1959
+ const selectedDate = parseCalendarDate(root.dataset.uiCalendarValue)
1960
+ const today = getCalendarToday()
1961
+ const candidates = [requestedDate, selectedDate, today, month]
1962
+
1963
+ for (const candidate of candidates) {
1964
+ if (
1965
+ candidate &&
1966
+ formatCalendarMonth(candidate) === formatCalendarMonth(month) &&
1967
+ !isCalendarDateDisabled(root, candidate, min, max)
1968
+ ) {
1969
+ return candidate
1970
+ }
1971
+ }
1972
+
1973
+ for (let index = 0; index < getCalendarDaysInMonth(month); index += 1) {
1974
+ const date = addCalendarDays(month, index)
1975
+
1976
+ if (!isCalendarDateDisabled(root, date, min, max)) return date
1977
+ }
1978
+
1979
+ return month
1980
+ }
1981
+
1982
+ const syncCalendarNavigation = (
1983
+ root: HTMLElement,
1984
+ month: Date,
1985
+ min: Date | null,
1986
+ max: Date | null
1987
+ ): void => {
1988
+ const previous = root.querySelector<HTMLButtonElement>('[data-ui-calendar-prev]')
1989
+ const next = root.querySelector<HTMLButtonElement>('[data-ui-calendar-next]')
1990
+ const disabled = root.dataset.disabled === 'true'
1991
+ const previousMonthLastDay = addCalendarDays(month, -1)
1992
+ const nextMonthFirstDay = addCalendarMonths(month, 1)
1993
+
1994
+ if (previous) {
1995
+ previous.disabled = disabled || compareCalendarDates(previousMonthLastDay, min) < 0
1996
+ }
1997
+
1998
+ if (next) {
1999
+ next.disabled = disabled || compareCalendarDates(nextMonthFirstDay, max) > 0
2000
+ }
2001
+ }
2002
+
2003
+ const renderCalendar = (
2004
+ root: HTMLElement,
2005
+ requestedFocusDate?: Date | null,
2006
+ shouldFocus = false
2007
+ ): void => {
2008
+ const label = root.querySelector<HTMLElement>('[data-ui-calendar-label]')
2009
+ const grid = root.querySelector<HTMLElement>('[data-ui-calendar-grid]')
2010
+
2011
+ if (!label || !grid) return
2012
+
2013
+ const locale = getCalendarLocale()
2014
+ const min = parseCalendarDate(root.dataset.uiCalendarMin)
2015
+ const max = parseCalendarDate(root.dataset.uiCalendarMax)
2016
+ const selectedDate = parseCalendarDate(root.dataset.uiCalendarValue)
2017
+ const todayIso = formatCalendarDate(getCalendarToday())
2018
+
2019
+ const month = parseCalendarMonth(root.dataset.uiCalendarMonth) ??
2020
+ (selectedDate ? new Date(Date.UTC(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1)) : null) ??
2021
+ getCalendarToday()
2022
+
2023
+ const visibleMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1))
2024
+ const focusDate = getCalendarFocusDate(root, visibleMonth, min, max, requestedFocusDate)
2025
+ const focusIso = formatCalendarDate(focusDate)
2026
+ const selectedIso = selectedDate ? formatCalendarDate(selectedDate) : ''
2027
+
2028
+ const monthLabel = new Intl.DateTimeFormat(locale, {
2029
+ month: 'long',
2030
+ timeZone: 'UTC',
2031
+ year: 'numeric'
2032
+ }).format(visibleMonth)
2033
+
2034
+ const dayLabel = new Intl.DateTimeFormat(locale, {
2035
+ day: 'numeric',
2036
+ month: 'long',
2037
+ timeZone: 'UTC',
2038
+ weekday: 'long',
2039
+ year: 'numeric'
2040
+ })
2041
+
2042
+ const weekdayLabel = new Intl.DateTimeFormat(locale, {
2043
+ timeZone: 'UTC',
2044
+ weekday: 'short'
2045
+ })
2046
+
2047
+ const table = grid instanceof HTMLTableElement ? grid : null
2048
+
2049
+ root.dataset.uiCalendarMonth = formatCalendarMonth(visibleMonth)
2050
+
2051
+ label.textContent = monthLabel
2052
+
2053
+ syncCalendarNavigation(root, visibleMonth, min, max)
2054
+
2055
+ const header = document.createElement('thead')
2056
+ const headerRow = document.createElement('tr')
2057
+
2058
+ headerRow.role = 'row'
2059
+
2060
+ for (let index = 0; index < 7; index += 1) {
2061
+ const cell = document.createElement('th')
2062
+
2063
+ cell.scope = 'col'
2064
+
2065
+ cell.role = 'columnheader'
2066
+
2067
+ cell.textContent = weekdayLabel.format(new Date(Date.UTC(2026, 0, 5 + index)))
2068
+
2069
+ headerRow.append(cell)
2070
+ }
2071
+
2072
+ header.append(headerRow)
2073
+
2074
+ const body = document.createElement('tbody')
2075
+ const firstCell = getCalendarGridStart(visibleMonth)
2076
+
2077
+ for (let rowIndex = 0; rowIndex < 6; rowIndex += 1) {
2078
+ const row = document.createElement('tr')
2079
+
2080
+ row.role = 'row'
2081
+
2082
+ for (let columnIndex = 0; columnIndex < 7; columnIndex += 1) {
2083
+ const date = addCalendarDays(firstCell, rowIndex * 7 + columnIndex)
2084
+ const dateIso = formatCalendarDate(date)
2085
+ const cell = document.createElement('td')
2086
+ const unavailable = isCalendarDateDisabled(root, date, min, max)
2087
+
2088
+ cell.role = 'gridcell'
2089
+
2090
+ cell.tabIndex = !unavailable && dateIso === focusIso ? 0 : -1
2091
+
2092
+ cell.textContent = String(date.getUTCDate())
2093
+
2094
+ cell.dataset.date = dateIso
2095
+
2096
+ cell.dataset.uiCalendarDay = ''
2097
+
2098
+ cell.setAttribute('aria-label', dayLabel.format(date))
2099
+
2100
+ cell.setAttribute('aria-selected', String(selectedIso === dateIso))
2101
+
2102
+ if (unavailable) {
2103
+ cell.setAttribute('aria-disabled', 'true')
2104
+ }
2105
+
2106
+ if (formatCalendarMonth(date) !== formatCalendarMonth(visibleMonth)) {
2107
+ cell.dataset.outside = 'true'
2108
+ }
2109
+
2110
+ if (dateIso === todayIso) {
2111
+ cell.dataset.today = 'true'
2112
+ }
2113
+
2114
+ if (selectedIso === dateIso) {
2115
+ cell.dataset.selected = 'true'
2116
+ }
2117
+
2118
+ row.append(cell)
2119
+ }
2120
+
2121
+ body.append(row)
2122
+ }
2123
+
2124
+ if (table) {
2125
+ table.replaceChildren(header, body)
2126
+ } else {
2127
+ grid.replaceChildren(header, body)
2128
+ }
2129
+
2130
+ if (shouldFocus) {
2131
+ root.querySelector<HTMLElement>(`[data-ui-calendar-day][data-date="${focusIso}"]`)?.focus({ preventScroll: true })
2132
+ }
2133
+ }
2134
+
2135
+ const selectCalendarDate = (root: HTMLElement, date: Date): void => {
2136
+ const input = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2137
+
2138
+ if (!input || root.dataset.disabled === 'true') return
2139
+
2140
+ const min = parseCalendarDate(root.dataset.uiCalendarMin)
2141
+ const max = parseCalendarDate(root.dataset.uiCalendarMax)
2142
+ const nextDate = clampCalendarDate(date, min, max)
2143
+
2144
+ if (isCalendarDateDisabled(root, nextDate, min, max)) return
2145
+
2146
+ input.value = formatCalendarDate(nextDate)
2147
+
2148
+ root.dataset.uiCalendarValue = input.value
2149
+
2150
+ root.dataset.uiCalendarMonth = formatCalendarMonth(nextDate)
2151
+
2152
+ input.dispatchEvent(new Event('input', { bubbles: true }))
2153
+
2154
+ input.dispatchEvent(new Event('change', { bubbles: true }))
2155
+
2156
+ renderCalendar(root, nextDate, true)
2157
+ }
2158
+
2159
+ const focusCalendarDate = (root: HTMLElement, date: Date): void => {
2160
+ const min = parseCalendarDate(root.dataset.uiCalendarMin)
2161
+ const max = parseCalendarDate(root.dataset.uiCalendarMax)
2162
+ const nextDate = clampCalendarDate(date, min, max)
2163
+
2164
+ root.dataset.uiCalendarMonth = formatCalendarMonth(nextDate)
2165
+
2166
+ renderCalendar(root, nextDate, true)
2167
+ }
2168
+
2169
+ const moveCalendarFocus = (root: HTMLElement, currentDate: Date, key: string): void => {
2170
+ const column = (currentDate.getUTCDay() + 6) % 7
2171
+
2172
+ const keyOffsets: Record<string, number> = {
2173
+ ArrowDown: 7,
2174
+ ArrowLeft: -1,
2175
+ ArrowRight: 1,
2176
+ ArrowUp: -7,
2177
+ End: 6 - column,
2178
+ Home: -column
2179
+ }
2180
+
2181
+ if (key === 'PageDown' || key === 'PageUp') {
2182
+ focusCalendarDate(root, addCalendarMonths(currentDate, key === 'PageDown' ? 1 : -1))
2183
+
2184
+ return
2185
+ }
2186
+
2187
+ const offset = keyOffsets[key]
2188
+
2189
+ if (offset === undefined) return
2190
+
2191
+ focusCalendarDate(root, addCalendarDays(currentDate, offset))
2192
+ }
2193
+
2194
+ const initCalendars = (scope: ParentNode): void => {
2195
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-calendar]')) {
2196
+ if (root.dataset.uiBound === 'true') continue
2197
+
2198
+ const input = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2199
+
2200
+ if (!input) continue
2201
+
2202
+ root.dataset.uiBound = 'true'
2203
+
2204
+ renderCalendar(root)
2205
+
2206
+ root.querySelector<HTMLButtonElement>('[data-ui-calendar-prev]')?.addEventListener('click', () => {
2207
+ const month = parseCalendarMonth(root.dataset.uiCalendarMonth)
2208
+
2209
+ if (!month) return
2210
+
2211
+ const nextMonth = addCalendarMonths(month, -1)
2212
+
2213
+ root.dataset.uiCalendarMonth = formatCalendarMonth(nextMonth)
2214
+
2215
+ renderCalendar(root, nextMonth, true)
2216
+ })
2217
+
2218
+ root.querySelector<HTMLButtonElement>('[data-ui-calendar-next]')?.addEventListener('click', () => {
2219
+ const month = parseCalendarMonth(root.dataset.uiCalendarMonth)
2220
+
2221
+ if (!month) return
2222
+
2223
+ const nextMonth = addCalendarMonths(month, 1)
2224
+
2225
+ root.dataset.uiCalendarMonth = formatCalendarMonth(nextMonth)
2226
+
2227
+ renderCalendar(root, nextMonth, true)
2228
+ })
2229
+
2230
+ root.addEventListener('click', event => {
2231
+ const target = event.target
2232
+
2233
+ if (!(target instanceof Element)) return
2234
+
2235
+ const cell = target.closest<HTMLElement>('[data-ui-calendar-day]')
2236
+
2237
+ if (!cell || !root.contains(cell) || cell.getAttribute('aria-disabled') === 'true') return
2238
+
2239
+ const date = parseCalendarDate(cell.dataset.date)
2240
+
2241
+ if (date) {
2242
+ selectCalendarDate(root, date)
2243
+ }
2244
+ })
2245
+
2246
+ root.addEventListener('keydown', event => {
2247
+ const target = event.target
2248
+
2249
+ if (!(target instanceof HTMLElement) || !target.hasAttribute('data-ui-calendar-day')) return
2250
+
2251
+ const date = parseCalendarDate(target.dataset.date)
2252
+
2253
+ if (!date) return
2254
+
2255
+ if (event.key === 'Enter' || event.key === ' ') {
2256
+ event.preventDefault()
2257
+
2258
+ selectCalendarDate(root, date)
2259
+
2260
+ return
2261
+ }
2262
+
2263
+ if (
2264
+ event.key !== 'ArrowDown' &&
2265
+ event.key !== 'ArrowLeft' &&
2266
+ event.key !== 'ArrowRight' &&
2267
+ event.key !== 'ArrowUp' &&
2268
+ event.key !== 'End' &&
2269
+ event.key !== 'Home' &&
2270
+ event.key !== 'PageDown' &&
2271
+ event.key !== 'PageUp'
2272
+ ) {
2273
+ return
2274
+ }
2275
+
2276
+ event.preventDefault()
2277
+
2278
+ moveCalendarFocus(root, date, event.key)
2279
+ })
2280
+
2281
+ input.form?.addEventListener('reset', () => {
2282
+ window.setTimeout(() => {
2283
+ root.dataset.uiCalendarValue = input.value
2284
+
2285
+ root.dataset.uiCalendarMonth = root.dataset.uiCalendarInitialMonth ?? root.dataset.uiCalendarMonth
2286
+
2287
+ renderCalendar(root)
2288
+ })
2289
+ })
2290
+ }
2291
+ }
2292
+
2293
+ const formatDatePickerValue = (value: string, placeholder: string): string => {
2294
+ const date = parseCalendarDate(value)
2295
+
2296
+ if (!date) return placeholder
2297
+
2298
+ return new Intl.DateTimeFormat(getCalendarLocale(), {
2299
+ day: 'numeric',
2300
+ month: 'short',
2301
+ timeZone: 'UTC',
2302
+ year: 'numeric'
2303
+ }).format(date)
2304
+ }
2305
+
2306
+ const closeDatePicker = (root: HTMLElement, restoreFocus = false): void => {
2307
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-date-picker-trigger]')
2308
+ const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
2309
+
2310
+ if (!trigger || !popover || popover.hidden) return
2311
+
2312
+ popover.hidden = true
2313
+
2314
+ popover.dataset.state = 'closed'
2315
+
2316
+ trigger.setAttribute('aria-expanded', 'false')
2317
+
2318
+ if (restoreFocus) trigger.focus()
2319
+ }
2320
+
2321
+ const syncDatePickerValue = (root: HTMLElement): void => {
2322
+ const native = root.querySelector<HTMLInputElement>('[data-ui-date-picker-native]')
2323
+ const value = root.querySelector<HTMLElement>('[data-ui-date-picker-value]')
2324
+
2325
+ if (!native || !value) return
2326
+
2327
+ const placeholder = native.getAttribute('placeholder') || 'Choose a date'
2328
+
2329
+ value.textContent = formatDatePickerValue(native.value, placeholder)
2330
+
2331
+ if (native.value) {
2332
+ delete root.dataset.placeholder
2333
+ } else {
2334
+ root.dataset.placeholder = 'true'
2335
+ }
2336
+
2337
+ if (native.validity.valid) {
2338
+ root.querySelector<HTMLElement>('[data-ui-date-picker-trigger]')?.removeAttribute('aria-invalid')
2339
+ }
2340
+ }
2341
+
2342
+ const openDatePicker = (root: HTMLElement): void => {
2343
+ const native = root.querySelector<HTMLInputElement>('[data-ui-date-picker-native]')
2344
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-date-picker-trigger]')
2345
+ const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
2346
+ const calendar = root.querySelector<HTMLElement>('[data-ui-calendar]')
2347
+
2348
+ if (!native || !trigger || !popover || native.disabled) return
2349
+
2350
+ for (const other of document.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
2351
+ if (other !== root) closeDatePicker(other)
2352
+ }
2353
+
2354
+ if (calendar) {
2355
+ calendar.dataset.uiCalendarValue = native.value
2356
+
2357
+ if (native.value) calendar.dataset.uiCalendarMonth = native.value.slice(0, 7)
2358
+
2359
+ if (native.min) calendar.dataset.uiCalendarMin = native.min
2360
+ else delete calendar.dataset.uiCalendarMin
2361
+
2362
+ if (native.max) calendar.dataset.uiCalendarMax = native.max
2363
+ else delete calendar.dataset.uiCalendarMax
2364
+
2365
+ renderCalendar(calendar)
2366
+ }
2367
+
2368
+ popover.hidden = false
2369
+
2370
+ popover.dataset.state = 'open'
2371
+
2372
+ trigger.setAttribute('aria-expanded', 'true')
2373
+
2374
+ window.requestAnimationFrame(() => {
2375
+ calendar?.querySelector<HTMLElement>('[data-ui-calendar-day][tabindex="0"]')?.focus({ preventScroll: true })
2376
+ })
2377
+ }
2378
+
2379
+ const initDatePickers = (scope: ParentNode): void => {
2380
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
2381
+ if (root.dataset.uiDatePickerBound === 'true') continue
2382
+
2383
+ const native = root.querySelector<HTMLInputElement>('[data-ui-date-picker-native]')
2384
+ const control = root.querySelector<HTMLElement>('[data-ui-date-picker-control]')
2385
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-date-picker-trigger]')
2386
+ const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
2387
+ const calendarInput = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2388
+
2389
+ if (!native || !control || !trigger || !popover || !calendarInput) continue
2390
+
2391
+ root.dataset.uiDatePickerBound = 'true'
2392
+
2393
+ native.dataset.uiEnhanced = 'true'
2394
+
2395
+ native.setAttribute('aria-hidden', 'true')
2396
+
2397
+ native.tabIndex = -1
2398
+
2399
+ control.hidden = false
2400
+
2401
+ const accessibleName = native.getAttribute('aria-label')
2402
+ const labelledBy = native.getAttribute('aria-labelledby')
2403
+ const describedBy = native.getAttribute('aria-describedby')
2404
+
2405
+ if (accessibleName) trigger.setAttribute('aria-label', accessibleName)
2406
+
2407
+ if (labelledBy) trigger.setAttribute('aria-labelledby', labelledBy)
2408
+
2409
+ if (describedBy) trigger.setAttribute('aria-describedby', describedBy)
2410
+
2411
+ syncDatePickerValue(root)
2412
+
2413
+ trigger.addEventListener('click', () => {
2414
+ if (popover.hidden) openDatePicker(root)
2415
+ else closeDatePicker(root)
2416
+ })
2417
+
2418
+ trigger.addEventListener('keydown', event => {
2419
+ if (event.key !== 'ArrowDown') return
2420
+
2421
+ event.preventDefault()
2422
+
2423
+ openDatePicker(root)
2424
+ })
2425
+
2426
+ calendarInput.addEventListener('change', () => {
2427
+ native.value = calendarInput.value
2428
+
2429
+ syncDatePickerValue(root)
2430
+
2431
+ native.dispatchEvent(new Event('input', { bubbles: true }))
2432
+
2433
+ native.dispatchEvent(new Event('change', { bubbles: true }))
2434
+
2435
+ closeDatePicker(root, true)
2436
+ })
2437
+
2438
+ native.addEventListener('change', () => syncDatePickerValue(root))
2439
+
2440
+ native.addEventListener('invalid', event => {
2441
+ event.preventDefault()
2442
+
2443
+ trigger.setAttribute('aria-invalid', 'true')
2444
+
2445
+ trigger.focus()
2446
+ })
2447
+
2448
+ native.form?.addEventListener('reset', () => {
2449
+ window.setTimeout(() => {
2450
+ syncDatePickerValue(root)
2451
+
2452
+ closeDatePicker(root)
2453
+ })
2454
+ })
2455
+ }
2456
+ }
2457
+
2458
+ const initCarousel = (scope: ParentNode): void => {
2459
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-carousel]')) {
2460
+ if (root.dataset.uiBound === 'true') continue
2461
+
2462
+ const viewport = root.querySelector<HTMLElement>('[data-ui-carousel-viewport]')
2463
+
2464
+ if (!viewport) continue
2465
+
2466
+ root.dataset.uiBound = 'true'
2467
+
2468
+ for (const button of root.querySelectorAll<HTMLElement>('[data-ui-carousel-prev]')) {
2469
+ button.addEventListener('click', () => viewport.scrollBy({ behavior: 'smooth', left: -viewport.clientWidth }))
2470
+ }
2471
+
2472
+ for (const button of root.querySelectorAll<HTMLElement>('[data-ui-carousel-next]')) {
2473
+ button.addEventListener('click', () => viewport.scrollBy({ behavior: 'smooth', left: viewport.clientWidth }))
2474
+ }
2475
+ }
2476
+ }
2477
+
2478
+ const initSchedules = (scope: ParentNode): void => {
2479
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-schedule]')) {
2480
+ if (root.dataset.uiBound === 'true') continue
2481
+
2482
+ root.dataset.uiBound = 'true'
2483
+
2484
+ for (const event of root.querySelectorAll<HTMLElement>('[data-ui-schedule-event]')) {
2485
+ event.draggable = event.draggable || event.dataset.uiDraggable === 'true'
2486
+
2487
+ event.addEventListener('dragstart', dragEvent => {
2488
+ dragEvent.dataTransfer?.setData('text/plain', event.id || event.textContent?.trim() || '')
2489
+
2490
+ root.dataset.uiDragging = 'true'
2491
+ })
2492
+
2493
+ event.addEventListener('dragend', () => {
2494
+ delete root.dataset.uiDragging
2495
+ })
2496
+ }
2497
+
2498
+ for (const dropzone of root.querySelectorAll<HTMLElement>('[data-ui-schedule-slot]')) {
2499
+ dropzone.addEventListener('dragover', event => {
2500
+ event.preventDefault()
2501
+
2502
+ dropzone.dataset.state = 'drag-over'
2503
+ })
2504
+
2505
+ dropzone.addEventListener('dragleave', () => {
2506
+ delete dropzone.dataset.state
2507
+ })
2508
+
2509
+ dropzone.addEventListener('drop', event => {
2510
+ event.preventDefault()
2511
+
2512
+ delete dropzone.dataset.state
2513
+
2514
+ const draggedId = event.dataTransfer?.getData('text/plain')
2515
+ const dragged = draggedId ? document.getElementById(draggedId) : null
2516
+
2517
+ if (dragged instanceof HTMLElement) {
2518
+ dropzone.append(dragged)
2519
+ }
2520
+
2521
+ root.dispatchEvent(new CustomEvent('ui:schedule-change', {
2522
+ bubbles: true,
2523
+ detail: {
2524
+ eventId: draggedId,
2525
+ slot: dropzone.dataset.uiScheduleSlot
2526
+ }
2527
+ }))
2528
+ })
2529
+ }
2530
+ }
2531
+ }
2532
+
2533
+ const initTreeCollections = (scope: ParentNode): void => {
2534
+ for (const root of scope.querySelectorAll<HTMLElement>('[role="tree"], [role="treegrid"]')) {
2535
+ if (root.dataset.uiBound === 'true') continue
2536
+
2537
+ const items = [...root.querySelectorAll<HTMLElement>('[role="treeitem"], [role="row"]')]
2538
+
2539
+ if (!items.length) continue
2540
+
2541
+ root.dataset.uiBound = 'true'
2542
+
2543
+ for (const [index, item] of items.entries()) {
2544
+ item.tabIndex = index === 0 ? 0 : -1
2545
+
2546
+ item.addEventListener('keydown', event => {
2547
+ const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ']
2548
+
2549
+ if (!keys.includes(event.key)) return
2550
+
2551
+ if ((event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') && item.hasAttribute('aria-expanded')) {
2552
+ event.preventDefault()
2553
+
2554
+ item.setAttribute('aria-expanded', 'true')
2555
+
2556
+ return
2557
+ }
2558
+
2559
+ if (event.key === 'ArrowLeft' && item.hasAttribute('aria-expanded')) {
2560
+ event.preventDefault()
2561
+
2562
+ item.setAttribute('aria-expanded', 'false')
2563
+
2564
+ return
2565
+ }
2566
+
2567
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
2568
+
2569
+ event.preventDefault()
2570
+
2571
+ for (const nextItem of items) {
2572
+ nextItem.tabIndex = -1
2573
+ }
2574
+
2575
+ const nextItem = items[getLoopedIndex(event.key, index, items.length, ['ArrowDown'])]
2576
+
2577
+ if (nextItem) {
2578
+ nextItem.tabIndex = 0
2579
+
2580
+ nextItem.focus()
2581
+ }
2582
+ })
2583
+ }
2584
+ }
2585
+ }
2586
+
2587
+ const initVirtualLists = (scope: ParentNode): void => {
2588
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-virtual-list]')) {
2589
+ if (root.dataset.uiBound === 'true') continue
2590
+
2591
+ const items = [...root.children].filter((child): child is HTMLElement => child instanceof HTMLElement)
2592
+
2593
+ if (!items.length) continue
2594
+
2595
+ root.dataset.uiBound = 'true'
2596
+
2597
+ const overscan = Number(root.dataset.uiOverscan ?? 4)
2598
+ const itemSize = Number(root.dataset.uiItemSize ?? 44)
2599
+
2600
+ const update = (): void => {
2601
+ const visibleCount = Math.ceil(root.clientHeight / itemSize) + overscan
2602
+ const startIndex = Math.max(0, Math.floor(root.scrollTop / itemSize) - overscan)
2603
+ const endIndex = startIndex + visibleCount
2604
+
2605
+ for (const [index, item] of items.entries()) {
2606
+ item.hidden = index < startIndex || index > endIndex
2607
+ }
2608
+
2609
+ root.dispatchEvent(new CustomEvent('ui:virtual-list-range', {
2610
+ bubbles: true,
2611
+ detail: {
2612
+ endIndex: Math.min(items.length - 1, endIndex),
2613
+ startIndex
2614
+ }
2615
+ }))
2616
+ }
2617
+
2618
+ root.addEventListener('scroll', update, { passive: true })
2619
+
2620
+ update()
2621
+ }
2622
+ }
2623
+
2624
+ const initTagGroups = (scope: ParentNode): void => {
2625
+ for (const root of scope.querySelectorAll<HTMLElement>('.ui-tag-group, [data-ui-tag-group]')) {
2626
+ if (root.dataset.uiBound === 'true') continue
2627
+
2628
+ root.dataset.uiBound = 'true'
2629
+
2630
+ for (const button of root.querySelectorAll<HTMLElement>('[data-ui-tag-remove]')) {
2631
+ button.addEventListener('click', () => {
2632
+ const tag = button.closest<HTMLElement>('[data-ui-tag], [role="listitem"]')
2633
+
2634
+ tag?.remove()
2635
+
2636
+ root.dispatchEvent(new CustomEvent('ui:tag-remove', {
2637
+ bubbles: true,
2638
+ detail: {
2639
+ value: tag?.dataset.value ?? tag?.textContent?.trim()
2640
+ }
2641
+ }))
2642
+ })
2643
+ }
2644
+ }
2645
+ }
2646
+
2647
+ const richTextEditorContentSelector = '[data-ui-rich-text-editable], [contenteditable="true"]'
2648
+
2649
+ const getRichTextCommandValue = (control: HTMLElement): string | undefined => {
2650
+ if (control.dataset.uiEditorValue !== undefined) return control.dataset.uiEditorValue
2651
+
2652
+ if (
2653
+ control instanceof HTMLInputElement
2654
+ || control instanceof HTMLSelectElement
2655
+ || control instanceof HTMLTextAreaElement
2656
+ ) return control.value
2657
+
2658
+ return undefined
2659
+ }
2660
+
2661
+ const syncRichTextCommandStates = (root: HTMLElement): void => {
2662
+ const commandDocument = document as unknown as RichTextCommandDocument
2663
+
2664
+ for (const control of root.querySelectorAll<HTMLElement>('[data-ui-editor-command]')) {
2665
+ const command = control.dataset.uiEditorCommand ?? ''
2666
+ const value = getRichTextCommandValue(control)
2667
+ let active = false
2668
+
2669
+ if (isLumenRichTextToggleCommand(command)) {
2670
+ active = commandDocument.queryCommandState?.(command) ?? false
2671
+
2672
+ control.setAttribute('aria-pressed', String(active))
2673
+ } else if (command === 'formatBlock' && value) {
2674
+ const currentValue = commandDocument.queryCommandValue?.(command)
2675
+ .replaceAll(/[<>]/g, '')
2676
+ .toLowerCase()
2677
+
2678
+ active = currentValue === value.replaceAll(/[<>]/g, '').toLowerCase()
2679
+ }
2680
+
2681
+ control.dataset.state = active ? 'on' : 'off'
2682
+
2683
+ }
2684
+ }
2685
+
2686
+ const dispatchRichTextChange = (root: HTMLElement): void => {
2687
+ const editable = root.querySelector<HTMLElement>(richTextEditorContentSelector)
2688
+
2689
+ if (!editable) return
2690
+
2691
+ const detail: LumenRichTextChangeDetail = {
2692
+ html: editable.innerHTML,
2693
+ text: editable.textContent ?? ''
2694
+ }
2695
+
2696
+ root.dispatchEvent(new CustomEvent<LumenRichTextChangeDetail>('ui:editor-change', {
2697
+ bubbles: true,
2698
+ detail
2699
+ }))
2700
+ }
2701
+
2702
+ const executeRichTextCommand = (
2703
+ root: HTMLElement,
2704
+ command: string,
2705
+ value?: string
2706
+ ): boolean => {
2707
+ if (!command) return false
2708
+
2709
+ const commandDocument = document as unknown as RichTextCommandDocument
2710
+ let executed = false
2711
+
2712
+ if (typeof commandDocument.execCommand === 'function') {
2713
+ executed = value === undefined
2714
+ ? commandDocument.execCommand(command)
2715
+ : commandDocument.execCommand(command, false, value)
2716
+ }
2717
+
2718
+ const detail: LumenRichTextCommandDetail = {
2719
+ command,
2720
+ executed,
2721
+ ...(value === undefined ? {} : { value })
2722
+ }
2723
+
2724
+ root.dispatchEvent(new CustomEvent<LumenRichTextCommandDetail>('ui:editor-command', {
2725
+ bubbles: true,
2726
+ detail
2727
+ }))
2728
+
2729
+ syncRichTextCommandStates(root)
2730
+
2731
+ dispatchRichTextChange(root)
2732
+
2733
+ return executed
2734
+ }
2735
+
2736
+ const initRichTextEditors = (scope: ParentNode): void => {
2737
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-rich-text-editor]')) {
2738
+ if (root.dataset.uiBound === 'true') continue
2739
+
2740
+ root.dataset.uiBound = 'true'
2741
+
2742
+ for (const control of root.querySelectorAll<HTMLElement>('[data-ui-editor-command]')) {
2743
+ const execute = () => {
2744
+ executeRichTextCommand(
2745
+ root,
2746
+ control.dataset.uiEditorCommand ?? '',
2747
+ getRichTextCommandValue(control)
2748
+ )
2749
+ }
2750
+
2751
+ if (control instanceof HTMLSelectElement) control.addEventListener('change', execute)
2752
+ else control.addEventListener('click', execute)
2753
+ }
2754
+
2755
+ root.addEventListener('keydown', event => {
2756
+ if (!(event.target instanceof HTMLElement)
2757
+ || !event.target.closest(richTextEditorContentSelector)) return
2758
+
2759
+ const command = getLumenRichTextShortcut(event)
2760
+
2761
+ if (!command) return
2762
+
2763
+ event.preventDefault()
2764
+
2765
+ executeRichTextCommand(root, command)
2766
+ })
2767
+
2768
+ root.addEventListener('input', event => {
2769
+ if (!(event.target instanceof HTMLElement)
2770
+ || !event.target.closest(richTextEditorContentSelector)) return
2771
+
2772
+ syncRichTextCommandStates(root)
2773
+
2774
+ dispatchRichTextChange(root)
2775
+ })
2776
+
2777
+ root.addEventListener('keyup', () => { syncRichTextCommandStates(root); })
2778
+
2779
+ root.addEventListener('mouseup', () => { syncRichTextCommandStates(root); })
2780
+
2781
+ syncRichTextCommandStates(root)
2782
+ }
2783
+ }
2784
+
2785
+ const themeBuilderExportButtonLabels: Record<ThemeBuilderExportFormat, string> = {
2786
+ css: 'Copy CSS',
2787
+ figma: 'Copy Figma JSON',
2788
+ tokens: 'Copy token JSON'
2789
+ }
2790
+
2791
+ const coerceThemeBuilderExportFormat = (value?: string | null): ThemeBuilderExportFormat => {
2792
+ if (value === 'figma' || value === 'tokens') return value
2793
+
2794
+ return 'css'
2795
+ }
2796
+
2797
+ const coerceThemeBuilderMode = (value?: string | null): ThemeBuilderMode =>
2798
+ value === 'manual' ? 'manual' : 'generated'
2799
+
2800
+ const coerceThemeBuilderScheme = (value?: string | null): ThemeBuilderScheme =>
2801
+ value === 'dark' ? 'dark' : 'light'
2802
+
2803
+ const getDefaultThemeBuilderScheme = (): ThemeBuilderScheme =>
2804
+ document.documentElement.dataset.theme?.includes('dark') ? 'dark' : 'light'
2805
+
2806
+ const normalizeThemeBuilderHue = (
2807
+ value: number | string | null | undefined,
2808
+ fallback = 0
2809
+ ): number => {
2810
+ const parsed = Number(value)
2811
+
2812
+ if (!Number.isFinite(parsed)) return fallback
2813
+
2814
+ return ((Math.round(parsed) % 360) + 360) % 360
2815
+ }
2816
+
2817
+ const normalizeThemeBuilderHex = (value: string): string | null => {
2818
+ const hex = value.trim().replace(/^#/, '')
2819
+
2820
+ if (/^[\da-f]{3}$/i.test(hex)) {
2821
+ return `#${[...hex].map(character => `${character}${character}`).join('').toLowerCase()}`
2822
+ }
2823
+
2824
+ return /^[\da-f]{6}$/i.test(hex) ? `#${hex.toLowerCase()}` : null
2825
+ }
2826
+
2827
+ const hexToThemeBuilderHsl = (value: string): { hue: number, value: string } | null => {
2828
+ const normalized = normalizeThemeBuilderHex(value)
2829
+
2830
+ if (!normalized) return null
2831
+
2832
+ const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
2833
+ const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
2834
+ const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
2835
+ const max = Math.max(red, green, blue)
2836
+ const min = Math.min(red, green, blue)
2837
+ const lightness = (max + min) / 2
2838
+ const delta = max - min
2839
+ let hue = 0
2840
+ let saturation = 0
2841
+
2842
+ if (delta !== 0) {
2843
+ saturation = delta / (1 - Math.abs(2 * lightness - 1))
2844
+
2845
+ if (max === red) {
2846
+ hue = 60 * (((green - blue) / delta) % 6)
2847
+ } else if (max === green) {
2848
+ hue = 60 * ((blue - red) / delta + 2)
2849
+ } else {
2850
+ hue = 60 * ((red - green) / delta + 4)
2851
+ }
2852
+ }
2853
+
2854
+ const normalizedHue = normalizeThemeBuilderHue(hue)
2855
+
2856
+ return {
2857
+ hue: normalizedHue,
2858
+ value: `${normalizedHue} ${Math.round(saturation * 100)}% ${Math.round(lightness * 100)}%`
2859
+ }
2860
+ }
2861
+
2862
+ const setThemeBuilderHexValidity = (input: HTMLInputElement, isValid: boolean): void => {
2863
+ if (isValid) {
2864
+ input.removeAttribute('aria-invalid')
2865
+
2866
+ return
2867
+ }
2868
+
2869
+ input.setAttribute('aria-invalid', 'true')
2870
+ }
2871
+
2872
+ const getThemeBuilderTarget = (root: HTMLElement): HTMLElement | null => {
2873
+ const selector = root.dataset.uiThemeTarget
2874
+
2875
+ if (!selector) return document.documentElement
2876
+
2877
+ try {
2878
+ return document.querySelector<HTMLElement>(selector)
2879
+ } catch {
2880
+ return null
2881
+ }
2882
+ }
2883
+
2884
+ const getThemeBuilderButtonValue = (
2885
+ root: HTMLElement,
2886
+ buttons: HTMLButtonElement[],
2887
+ attribute: string,
2888
+ fallback: string
2889
+ ): string => {
2890
+ const activeButton = buttons.find(button =>
2891
+ button.getAttribute('aria-selected') === 'true' ||
2892
+ button.getAttribute('aria-pressed') === 'true'
2893
+ )
2894
+
2895
+ return activeButton?.getAttribute(attribute) ?? root.getAttribute(attribute) ?? fallback
2896
+ }
2897
+
2898
+ const setThemeBuilderPressedState = (
2899
+ buttons: HTMLButtonElement[],
2900
+ attribute: string,
2901
+ value: string
2902
+ ): void => {
2903
+ for (const button of buttons) {
2904
+ const isPressed = button.getAttribute(attribute) === value
2905
+
2906
+ if (button.hasAttribute('aria-pressed')) {
2907
+ button.setAttribute('aria-pressed', String(isPressed))
2908
+ }
2909
+
2910
+ button.classList.toggle('ui-button--secondary', isPressed)
2911
+
2912
+ button.classList.toggle('ui-button--outline', !isPressed)
2913
+ }
2914
+ }
2915
+
2916
+ const bindSyncedThemeBuilderHueInputs = (
2917
+ range: HTMLInputElement | null,
2918
+ number: HTMLInputElement | null,
2919
+ onChange: () => void
2920
+ ): void => {
2921
+ const sync = (source: HTMLInputElement, target: HTMLInputElement | null): void => {
2922
+ source.value = String(normalizeThemeBuilderHue(source.value))
2923
+
2924
+ if (target) {
2925
+ target.value = source.value
2926
+ }
2927
+
2928
+ onChange()
2929
+ }
2930
+
2931
+ range?.addEventListener('input', () => sync(range, number))
2932
+
2933
+ number?.addEventListener('input', () => sync(number, range))
2934
+ }
2935
+
2936
+ const bindThemeBuilderHexInputs = (
2937
+ color: HTMLInputElement | null,
2938
+ text: HTMLInputElement | null,
2939
+ onChange: () => void
2940
+ ): void => {
2941
+ color?.addEventListener('input', () => {
2942
+ if (!text) {
2943
+ onChange()
2944
+
2945
+ return
2946
+ }
2947
+
2948
+ text.value = color.value
2949
+
2950
+ setThemeBuilderHexValidity(text, true)
2951
+
2952
+ onChange()
2953
+ })
2954
+
2955
+ text?.addEventListener('change', () => {
2956
+ const normalized = normalizeThemeBuilderHex(text.value)
2957
+
2958
+ setThemeBuilderHexValidity(text, Boolean(normalized))
2959
+
2960
+ if (normalized) {
2961
+ if (color) color.value = normalized
2962
+
2963
+ text.value = normalized
2964
+ }
2965
+
2966
+ onChange()
2967
+ })
2968
+
2969
+ text?.addEventListener('input', () => {
2970
+ const normalized = normalizeThemeBuilderHex(text.value)
2971
+
2972
+ setThemeBuilderHexValidity(text, Boolean(normalized))
2973
+
2974
+ if (normalized) {
2975
+ if (color) color.value = normalized
2976
+
2977
+ onChange()
2978
+ }
2979
+ })
2980
+ }
2981
+
2982
+ const getComputedThemeBuilderTokens = (target: HTMLElement): LumenThemeTokens => {
2983
+ const styles = getComputedStyle(target)
2984
+ const tokens: LumenThemeTokens = {}
2985
+
2986
+ for (const token of lumenTokenNames) {
2987
+ const value = styles.getPropertyValue(`--${token}`).trim()
2988
+
2989
+ if (value) {
2990
+ tokens[token] = value
2991
+ }
2992
+ }
2993
+
2994
+ return tokens
2995
+ }
2996
+
2997
+ const applyThemeBuilderTokens = (
2998
+ target: HTMLElement,
2999
+ tokens: LumenThemeTokens,
3000
+ scheme: ThemeBuilderScheme
3001
+ ): void => {
3002
+ target.style.colorScheme = scheme
3003
+
3004
+ for (const token of lumenTokenNames) {
3005
+ target.style.setProperty(`--${token}`, tokens[token] ?? '')
3006
+ }
3007
+ }
3008
+
3009
+ const buildThemeBuilderCss = (
3010
+ tokens: LumenThemeTokens,
3011
+ scheme: ThemeBuilderScheme
3012
+ ): string => exportThemeCss(tokens).replace('{', `{\n color-scheme: ${scheme};`)
3013
+
3014
+ const buildThemeBuilderExport = (
3015
+ tokens: LumenThemeTokens,
3016
+ scheme: ThemeBuilderScheme,
3017
+ format: ThemeBuilderExportFormat
3018
+ ): string => {
3019
+ if (format === 'figma') {
3020
+ return JSON.stringify(exportThemeFigmaVariables(tokens, {
3021
+ collectionName: 'Lumen',
3022
+ modeName: scheme === 'dark' ? 'Dark' : 'Light'
3023
+ }), null, 2)
3024
+ }
3025
+
3026
+ if (format === 'tokens') {
3027
+ return JSON.stringify(exportThemeDesignTokens(tokens), null, 2)
3028
+ }
3029
+
3030
+ return buildThemeBuilderCss(tokens, scheme)
3031
+ }
3032
+
3033
+ const initThemeBuilders = (scope: ParentNode): void => {
3034
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-theme-builder]')) {
3035
+ if (root.dataset.uiThemeBuilderBound === 'true') continue
3036
+
3037
+ root.dataset.uiThemeBuilderBound = 'true'
3038
+
3039
+ root.dataset.uiBound = 'true'
3040
+
3041
+ const exportButton = root.querySelector<HTMLElement>('[data-ui-theme-export]')
3042
+ const output = root.querySelector<HTMLTextAreaElement | HTMLOutputElement>('[data-ui-theme-output]')
3043
+ const brandHue = root.querySelector<HTMLInputElement>('[data-ui-theme-brand-hue], [data-ui-theme-hue]')
3044
+ const brandHueNumber = root.querySelector<HTMLInputElement>('[data-ui-theme-brand-hue-number]')
3045
+ const accentHue = root.querySelector<HTMLInputElement>('[data-ui-theme-accent-hue]')
3046
+ const accentHueNumber = root.querySelector<HTMLInputElement>('[data-ui-theme-accent-hue-number]')
3047
+ const primaryColor = root.querySelector<HTMLInputElement>('[data-ui-theme-primary-color]')
3048
+ const primaryHex = root.querySelector<HTMLInputElement>('[data-ui-theme-primary-hex]')
3049
+ const secondaryColor = root.querySelector<HTMLInputElement>('[data-ui-theme-secondary-color]')
3050
+ const secondaryHex = root.querySelector<HTMLInputElement>('[data-ui-theme-secondary-hex]')
3051
+ const exportFormatButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-export-format]')]
3052
+ const modeButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-mode]')]
3053
+ const schemeButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-scheme]')]
3054
+
3055
+ const hasTokenControls = Boolean(
3056
+ brandHue ||
3057
+ brandHueNumber ||
3058
+ accentHue ||
3059
+ accentHueNumber ||
3060
+ primaryColor ||
3061
+ primaryHex ||
3062
+ secondaryColor ||
3063
+ secondaryHex ||
3064
+ root.hasAttribute('data-ui-theme-brand-hue') ||
3065
+ root.hasAttribute('data-ui-theme-accent-hue') ||
3066
+ root.hasAttribute('data-ui-theme-hue')
3067
+ )
3068
+
3069
+ let currentExportValue = ''
3070
+ let currentFormat = coerceThemeBuilderExportFormat(getThemeBuilderButtonValue(root, exportFormatButtons, 'data-ui-theme-export-format', 'css'))
3071
+ let currentScheme = coerceThemeBuilderScheme(getThemeBuilderButtonValue(root, schemeButtons, 'data-ui-theme-scheme', getDefaultThemeBuilderScheme()))
3072
+ let currentTokens: LumenThemeTokens | null = null
3073
+
3074
+ const updateExportButtonLabel = (): void => {
3075
+ if (!exportButton || exportFormatButtons.length === 0) return
3076
+
3077
+ exportButton.replaceChildren(themeBuilderExportButtonLabels[currentFormat])
3078
+ }
3079
+
3080
+ const writeExport = (tokens: LumenThemeTokens, scheme: ThemeBuilderScheme): void => {
3081
+ currentExportValue = buildThemeBuilderExport(tokens, scheme, currentFormat)
3082
+
3083
+ if (output) {
3084
+ output.value = currentExportValue
3085
+ }
3086
+
3087
+ updateExportButtonLabel()
3088
+ }
3089
+
3090
+ const writeComputedExport = (): void => {
3091
+ const target = getThemeBuilderTarget(root) ?? document.documentElement
3092
+
3093
+ currentTokens = getComputedThemeBuilderTokens(target)
3094
+
3095
+ writeExport(currentTokens, currentScheme)
3096
+ }
3097
+
3098
+ const update = (
3099
+ options: { apply?: boolean, dispatch?: boolean } = {}
3100
+ ): void => {
3101
+ const hue = normalizeThemeBuilderHue(
3102
+ brandHue?.value ??
3103
+ brandHueNumber?.value ??
3104
+ root.getAttribute('data-ui-theme-brand-hue') ??
3105
+ root.getAttribute('data-ui-theme-hue')
3106
+ )
3107
+
3108
+ const nextAccentHue = normalizeThemeBuilderHue(
3109
+ accentHue?.value ?? accentHueNumber?.value ?? root.getAttribute('data-ui-theme-accent-hue'),
3110
+ normalizeThemeBuilderHue(hue + 150)
3111
+ )
3112
+
3113
+ const mode = coerceThemeBuilderMode(getThemeBuilderButtonValue(root, modeButtons, 'data-ui-theme-mode', 'generated'))
3114
+ const primaryValue = primaryHex?.value ?? primaryColor?.value
3115
+ const secondaryValue = secondaryHex?.value ?? secondaryColor?.value
3116
+ const primary = primaryValue ? hexToThemeBuilderHsl(primaryValue) : null
3117
+ const secondary = secondaryValue ? hexToThemeBuilderHsl(secondaryValue) : null
3118
+ const baseHue = mode === 'manual' ? primary?.hue ?? hue : hue
3119
+ const baseAccentHue = mode === 'manual' ? secondary?.hue ?? nextAccentHue : nextAccentHue
3120
+
3121
+ const tokens = createThemeFromHue(baseHue, {
3122
+ accentHue: baseAccentHue,
3123
+ scheme: currentScheme
3124
+ })
3125
+
3126
+ if (mode === 'manual') {
3127
+ if (primary) {
3128
+ tokens.brand = primary.value
3129
+
3130
+ tokens['brand-solid'] = primary.value
3131
+ }
3132
+
3133
+ if (secondary) {
3134
+ tokens.accent = secondary.value
3135
+ }
3136
+ }
3137
+
3138
+ currentTokens = tokens
3139
+
3140
+ const target = getThemeBuilderTarget(root)
3141
+
3142
+ if (options.apply !== false && target) {
3143
+ applyThemeBuilderTokens(target, tokens, currentScheme)
3144
+ }
3145
+
3146
+ writeExport(tokens, currentScheme)
3147
+
3148
+ if (options.dispatch === false) return
3149
+
3150
+ root.dispatchEvent(new CustomEvent('ui:theme-change', {
3151
+ bubbles: true,
3152
+ detail: {
3153
+ accentHue: baseAccentHue,
3154
+ hue: baseHue,
3155
+ mode,
3156
+ scheme: currentScheme,
3157
+ tokens
3158
+ }
3159
+ }))
3160
+ }
3161
+
3162
+ bindSyncedThemeBuilderHueInputs(brandHue, brandHueNumber, update)
3163
+
3164
+ bindSyncedThemeBuilderHueInputs(accentHue, accentHueNumber, update)
3165
+
3166
+ bindThemeBuilderHexInputs(primaryColor, primaryHex, update)
3167
+
3168
+ bindThemeBuilderHexInputs(secondaryColor, secondaryHex, update)
3169
+
3170
+ for (const button of exportFormatButtons) {
3171
+ button.addEventListener('click', () => {
3172
+ currentFormat = coerceThemeBuilderExportFormat(button.getAttribute('data-ui-theme-export-format'))
3173
+
3174
+ setThemeBuilderPressedState(exportFormatButtons, 'data-ui-theme-export-format', currentFormat)
3175
+
3176
+ if (hasTokenControls) {
3177
+ update({ apply: false, dispatch: false })
3178
+ } else {
3179
+ writeComputedExport()
3180
+ }
3181
+ })
3182
+ }
3183
+
3184
+ for (const button of modeButtons) {
3185
+ button.addEventListener('click', () => {
3186
+ const mode = coerceThemeBuilderMode(button.getAttribute('data-ui-theme-mode'))
3187
+
3188
+ if (button.getAttribute('role') !== 'tab') {
3189
+ setThemeBuilderPressedState(modeButtons, 'data-ui-theme-mode', mode)
3190
+ }
3191
+
3192
+ update()
3193
+ })
3194
+
3195
+ button.addEventListener('keydown', event => {
3196
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
3197
+
3198
+ queueMicrotask(update)
3199
+ })
3200
+ }
3201
+
3202
+ for (const button of schemeButtons) {
3203
+ button.addEventListener('click', () => {
3204
+ currentScheme = coerceThemeBuilderScheme(button.getAttribute('data-ui-theme-scheme'))
3205
+
3206
+ setThemeBuilderPressedState(schemeButtons, 'data-ui-theme-scheme', currentScheme)
3207
+
3208
+ update()
3209
+ })
3210
+ }
3211
+
3212
+ exportButton?.addEventListener('click', async () => {
3213
+ if (hasTokenControls && !currentTokens) {
3214
+ update({ apply: false, dispatch: false })
3215
+ } else if (!hasTokenControls) {
3216
+ writeComputedExport()
3217
+ }
3218
+
3219
+ const value = output?.value || currentExportValue
3220
+
3221
+ await navigator.clipboard?.writeText(value).catch(() => null)
3222
+
3223
+ root.dispatchEvent(new CustomEvent('ui:theme-export', {
3224
+ bubbles: true,
3225
+ detail: {
3226
+ css: currentFormat === 'css' ? value : undefined,
3227
+ format: currentFormat,
3228
+ tokens: currentTokens,
3229
+ value
3230
+ }
3231
+ }))
3232
+ })
3233
+
3234
+ if (hasTokenControls) {
3235
+ update({
3236
+ apply: Boolean(root.dataset.uiThemeTarget || root.hasAttribute('data-ui-theme-apply-on-init')),
3237
+ dispatch: false
3238
+ })
3239
+ } else if (output) {
3240
+ writeComputedExport()
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ const closeContextMenu = (menu: HTMLElement): void => {
3246
+ if (menu.dataset.state !== 'open') return
3247
+
3248
+ menu.dataset.state = 'closed'
3249
+
3250
+ menu.hidden = true
3251
+ }
3252
+
3253
+ const openContextMenuAt = (menu: HTMLElement, x: number, y: number): void => {
3254
+ for (const other of document.querySelectorAll<HTMLElement>('[data-ui-context-menu][data-state="open"]')) {
3255
+ if (other !== menu) closeContextMenu(other)
3256
+ }
3257
+
3258
+ menu.hidden = false
3259
+
3260
+ menu.style.position = 'fixed'
3261
+
3262
+ menu.style.zIndex = '60'
3263
+
3264
+ menu.dataset.state = 'open'
3265
+
3266
+ const rect = menu.getBoundingClientRect()
3267
+ const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8))
3268
+ const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8))
3269
+
3270
+ menu.style.left = `${left}px`
3271
+
3272
+ menu.style.top = `${top}px`
3273
+
3274
+ getFocusable(menu)[0]?.focus()
3275
+ }
3276
+
3277
+ /**
3278
+ * Context menus stay inert (statically visible) unless an element points at
3279
+ * them with data-ui-context-menu-trigger="<menu id>". With a trigger bound,
3280
+ * the menu hides until right-click / Shift+F10 opens it at the pointer.
3281
+ */
3282
+ const initContextMenus = (scope: ParentNode): void => {
3283
+ for (const target of scope.querySelectorAll<HTMLElement>('[data-ui-context-menu-trigger]')) {
3284
+ if (target.dataset.uiBound === 'true') continue
3285
+
3286
+ target.dataset.uiBound = 'true'
3287
+
3288
+ const menuId = target.getAttribute('data-ui-context-menu-trigger')
3289
+ const menu = menuId ? document.getElementById(menuId) : null
3290
+
3291
+ if (!menu) continue
3292
+
3293
+ menu.hidden = true
3294
+
3295
+ menu.dataset.state = 'closed'
3296
+
3297
+ target.addEventListener('contextmenu', event => {
3298
+ event.preventDefault()
3299
+
3300
+ openContextMenuAt(menu, event.clientX, event.clientY)
3301
+ })
3302
+
3303
+ target.addEventListener('keydown', event => {
3304
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) return
3305
+
3306
+ event.preventDefault()
3307
+
3308
+ const rect = target.getBoundingClientRect()
3309
+
3310
+ openContextMenuAt(menu, rect.left + 16, rect.top + 16)
3311
+ })
3312
+
3313
+ menu.addEventListener('keydown', event => {
3314
+ if (event.key === 'Escape') {
3315
+ closeContextMenu(menu)
3316
+
3317
+ target.focus()
3318
+
3319
+ return
3320
+ }
3321
+
3322
+ if (!['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) return
3323
+
3324
+ const items = getFocusable(menu)
3325
+
3326
+ if (items.length === 0) return
3327
+
3328
+ event.preventDefault()
3329
+
3330
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement)
3331
+
3332
+ items[getLoopedIndex(event.key, currentIndex, items.length, ['ArrowDown'])]?.focus()
3333
+ })
3334
+
3335
+ menu.addEventListener('click', event => {
3336
+ if (event.target instanceof HTMLElement && event.target.closest('[role="menuitem"]')) {
3337
+ closeContextMenu(menu)
3338
+ }
3339
+ })
3340
+ }
3341
+ }
3342
+
3343
+ /**
3344
+ * Date range pickers keep the two native date inputs consistent: the end
3345
+ * input cannot go before the start, and vice versa.
3346
+ */
3347
+ const initDateRangePickers = (scope: ParentNode): void => {
3348
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-date-range-picker]')) {
3349
+ if (root.dataset.uiBound === 'true') continue
3350
+
3351
+ root.dataset.uiBound = 'true'
3352
+
3353
+ const inputs = root.querySelectorAll<HTMLInputElement>('input[type="date"]')
3354
+ const start = inputs[0]
3355
+ const end = inputs[inputs.length - 1]
3356
+
3357
+ if (!start || !end || start === end) continue
3358
+
3359
+ const syncRange = (): void => {
3360
+ const datePickers = root.querySelectorAll<HTMLElement>('[data-ui-date-picker]')
3361
+
3362
+ datePickers[0]?.setAttribute('data-range-part', 'start')
3363
+
3364
+ datePickers[datePickers.length - 1]?.setAttribute('data-range-part', 'end')
3365
+
3366
+ if (start.value) {
3367
+ end.min = start.value
3368
+ } else {
3369
+ end.removeAttribute('min')
3370
+ }
3371
+
3372
+ if (end.value) {
3373
+ start.max = end.value
3374
+ } else {
3375
+ start.removeAttribute('max')
3376
+ }
3377
+
3378
+ if (start.value && end.value && end.value < start.value) {
3379
+ end.value = start.value
3380
+
3381
+ end.dispatchEvent(new Event('change', { bubbles: true }))
3382
+ }
3383
+
3384
+ let rangeState = 'empty'
3385
+
3386
+ if (start.value && end.value) {
3387
+ rangeState = 'complete'
3388
+ } else if (start.value) {
3389
+ rangeState = 'selecting-end'
3390
+ }
3391
+
3392
+ root.dataset.rangeState = rangeState
3393
+ }
3394
+
3395
+ start.addEventListener('change', syncRange)
3396
+
3397
+ end.addEventListener('change', syncRange)
3398
+
3399
+ syncRange()
3400
+ }
3401
+ }
3402
+
3403
+ const getToastVariantClass = (variant: string | undefined): string => {
3404
+ if (variant === 'success') return 'ui-toast--success'
3405
+
3406
+ if (variant === 'warning') return 'ui-toast--warning'
3407
+
3408
+ if (variant === 'destructive') return 'ui-toast--destructive'
3409
+
3410
+ return ''
3411
+ }
3412
+
3413
+ type ToastPlacement =
3414
+ | 'bottom-center'
3415
+ | 'bottom-left'
3416
+ | 'bottom-right'
3417
+ | 'top-center'
3418
+ | 'top-left'
3419
+ | 'top-right'
3420
+
3421
+ type ToastAction = {
3422
+ event?: string
3423
+ label?: string
3424
+ onClick?: (event: MouseEvent, toast: HTMLElement) => void
3425
+ value?: unknown
3426
+ }
3427
+
3428
+ type ToastDetail = {
3429
+ action?: ToastAction
3430
+ description?: string
3431
+ duration?: number
3432
+ id?: string
3433
+ max?: number
3434
+ placement?: ToastPlacement
3435
+ title?: string
3436
+ variant?: string
3437
+ }
3438
+
3439
+ type ToastApi = {
3440
+ create: (detail: ToastDetail) => string
3441
+ dismiss: (id?: string) => void
3442
+ update: (id: string, detail: ToastDetail) => void
3443
+ }
3444
+
3445
+ const defaultToastDuration = 5000
3446
+ const defaultToastMax = 5
3447
+ const noop = (): void => undefined
3448
+
3449
+ const toastTimers = new WeakMap<HTMLElement, {
3450
+ cleanup: () => void
3451
+ duration: number
3452
+ remaining: number
3453
+ startedAt: number
3454
+ timer: number
3455
+ }>()
3456
+
3457
+ const isToastPlacement = (placement: string | undefined): placement is ToastPlacement =>
3458
+ placement === 'top-left' ||
3459
+ placement === 'top-center' ||
3460
+ placement === 'top-right' ||
3461
+ placement === 'bottom-left' ||
3462
+ placement === 'bottom-center' ||
3463
+ placement === 'bottom-right'
3464
+
3465
+ const getToastPlacement = (placement: string | undefined): ToastPlacement =>
3466
+ isToastPlacement(placement) ? placement : 'bottom-right'
3467
+
3468
+ const getToastViewport = (placement: string | undefined): HTMLElement => {
3469
+ const resolvedPlacement = getToastPlacement(placement)
3470
+ const shouldUseAnyViewport = placement === undefined
3471
+
3472
+ const existing = document.querySelector<HTMLElement>(`[data-ui-sonner][data-placement="${resolvedPlacement}"]`) ??
3473
+ (shouldUseAnyViewport ? document.querySelector<HTMLElement>('[data-ui-sonner]') : null)
3474
+
3475
+ if (existing) {
3476
+ existing.dataset.placement = existing.dataset.placement ?? resolvedPlacement
3477
+
3478
+ existing.setAttribute('aria-live', existing.getAttribute('aria-live') ?? 'polite')
3479
+
3480
+ existing.setAttribute('aria-atomic', existing.getAttribute('aria-atomic') ?? 'false')
3481
+
3482
+ return existing
3483
+ }
3484
+
3485
+ const viewport = document.createElement('div')
3486
+
3487
+ viewport.className = 'ui-sonner'
3488
+
3489
+ viewport.dataset.uiSonner = ''
3490
+
3491
+ viewport.dataset.placement = resolvedPlacement
3492
+
3493
+ viewport.setAttribute('aria-live', 'polite')
3494
+
3495
+ viewport.setAttribute('aria-atomic', 'false')
3496
+
3497
+ viewport.setAttribute('aria-label', 'Notifications')
3498
+
3499
+ document.body.append(viewport)
3500
+
3501
+ return viewport
3502
+ }
3503
+
3504
+ const clearToastTimer = (toast: HTMLElement): void => {
3505
+ const timer = toastTimers.get(toast)
3506
+
3507
+ if (!timer) return
3508
+
3509
+ window.clearTimeout(timer.timer)
3510
+
3511
+ timer.cleanup()
3512
+
3513
+ toastTimers.delete(toast)
3514
+ }
3515
+
3516
+ const dismissToast = (toast: HTMLElement): void => {
3517
+ clearToastTimer(toast)
3518
+
3519
+ toast.dataset.state = 'closed'
3520
+
3521
+ window.setTimeout(() => toast.remove(), 240)
3522
+ }
3523
+
3524
+ const scheduleToastDismiss = (toast: HTMLElement, duration: number): void => {
3525
+ clearToastTimer(toast)
3526
+
3527
+ if (!Number.isFinite(duration) || duration <= 0) return
3528
+
3529
+ const timerState = {
3530
+ cleanup: noop,
3531
+ duration,
3532
+ remaining: duration,
3533
+ startedAt: Date.now(),
3534
+ timer: 0
3535
+ }
3536
+
3537
+ const start = (): void => {
3538
+ timerState.startedAt = Date.now()
3539
+
3540
+ timerState.timer = window.setTimeout(() => dismissToast(toast), timerState.remaining)
3541
+ }
3542
+
3543
+ const pause = (): void => {
3544
+ window.clearTimeout(timerState.timer)
3545
+
3546
+ timerState.remaining = Math.max(0, timerState.remaining - (Date.now() - timerState.startedAt))
3547
+ }
3548
+
3549
+ const resume = (): void => {
3550
+ if (timerState.remaining > 0) start()
3551
+ }
3552
+
3553
+ timerState.cleanup = () => {
3554
+ toast.removeEventListener('mouseenter', pause)
3555
+
3556
+ toast.removeEventListener('mouseleave', resume)
3557
+
3558
+ toast.removeEventListener('focusin', pause)
3559
+
3560
+ toast.removeEventListener('focusout', resume)
3561
+ }
3562
+
3563
+ toast.addEventListener('mouseenter', pause)
3564
+
3565
+ toast.addEventListener('mouseleave', resume)
3566
+
3567
+ toast.addEventListener('focusin', pause)
3568
+
3569
+ toast.addEventListener('focusout', resume)
3570
+
3571
+ toastTimers.set(toast, timerState)
3572
+
3573
+ start()
3574
+ }
3575
+
3576
+ const setToastContent = (toast: HTMLElement, detail: ToastDetail): void => {
3577
+ const title = detail.title ?? toast.dataset.title ?? 'Notification'
3578
+ const description = detail.description ?? toast.dataset.description ?? ''
3579
+ const variant = detail.variant ?? toast.dataset.variant ?? 'default'
3580
+ const variantClass = getToastVariantClass(variant)
3581
+
3582
+ toast.className = variantClass ? `ui-toast ${variantClass}` : 'ui-toast'
3583
+
3584
+ toast.dataset.variant = variant
3585
+
3586
+ toast.dataset.title = title
3587
+
3588
+ toast.dataset.description = description
3589
+
3590
+ toast.setAttribute('role', variant === 'destructive' ? 'alert' : 'status')
3591
+
3592
+ toast.setAttribute('aria-live', variant === 'destructive' ? 'assertive' : 'polite')
3593
+
3594
+ toast.replaceChildren()
3595
+
3596
+ const body = document.createElement('div')
3597
+ const heading = document.createElement('strong')
3598
+
3599
+ body.className = 'ui-toast__body'
3600
+
3601
+ heading.textContent = title
3602
+
3603
+ body.append(heading)
3604
+
3605
+ if (description) {
3606
+ const text = document.createElement('p')
3607
+
3608
+ text.textContent = description
3609
+
3610
+ body.append(text)
3611
+ }
3612
+
3613
+ toast.append(body)
3614
+
3615
+ if (detail.action?.label) {
3616
+ const action = document.createElement('button')
3617
+
3618
+ action.className = 'ui-button ui-button--secondary ui-button--sm ui-toast__action'
3619
+
3620
+ action.type = 'button'
3621
+
3622
+ action.textContent = detail.action.label
3623
+
3624
+ action.addEventListener('click', event => {
3625
+ detail.action?.onClick?.(event, toast)
3626
+
3627
+ toast.dispatchEvent(new CustomEvent(detail.action?.event ?? 'ui:toast-action', {
3628
+ bubbles: true,
3629
+ detail: { id: toast.id, value: detail.action?.value }
3630
+ }))
3631
+
3632
+ dismissToast(toast)
3633
+ })
3634
+
3635
+ toast.append(action)
3636
+ }
3637
+
3638
+ const dismiss = document.createElement('button')
3639
+
3640
+ dismiss.className = 'ui-toast__dismiss'
3641
+
3642
+ dismiss.type = 'button'
3643
+
3644
+ dismiss.setAttribute('aria-label', 'Dismiss notification')
3645
+
3646
+ dismiss.textContent = 'Dismiss'
3647
+
3648
+ dismiss.addEventListener('click', () => dismissToast(toast))
3649
+
3650
+ toast.append(dismiss)
3651
+ }
3652
+
3653
+ const initToasts = (): void => {
3654
+ if (document.documentElement.dataset.uiToastsBound === 'true') return
3655
+
3656
+ document.documentElement.dataset.uiToastsBound = 'true'
3657
+
3658
+ const createToast = (detail: ToastDetail): string => {
3659
+ const viewport = getToastViewport(detail.placement)
3660
+ const id = detail.id ?? `ui-toast-${crypto.randomUUID()}`
3661
+ const duration = detail.duration ?? defaultToastDuration
3662
+ const max = detail.max ?? Number(viewport.dataset.uiToastMax ?? defaultToastMax)
3663
+ const existingToast = document.getElementById(id)
3664
+
3665
+ if (existingToast instanceof HTMLElement && existingToast.hasAttribute('data-ui-toast')) {
3666
+ existingToast.dataset.state = 'open'
3667
+
3668
+ setToastContent(existingToast, detail)
3669
+
3670
+ viewport.append(existingToast)
3671
+
3672
+ scheduleToastDismiss(existingToast, duration)
3673
+
3674
+ return id
3675
+ }
3676
+
3677
+ const toast = document.createElement('aside')
3678
+
3679
+ toast.id = id
3680
+
3681
+ toast.dataset.uiToast = ''
3682
+
3683
+ toast.dataset.state = 'open'
3684
+
3685
+ toast.addEventListener('keydown', event => {
3686
+ if (event.key !== 'Escape') return
3687
+
3688
+ event.preventDefault()
3689
+
3690
+ dismissToast(toast)
3691
+ })
3692
+
3693
+ setToastContent(toast, detail)
3694
+
3695
+ viewport.append(toast)
3696
+
3697
+ scheduleToastDismiss(toast, duration)
3698
+
3699
+ const toasts = [...viewport.querySelectorAll<HTMLElement>('[data-ui-toast]')]
3700
+
3701
+ for (const staleToast of toasts.slice(0, Math.max(0, toasts.length - max))) {
3702
+ dismissToast(staleToast)
3703
+ }
3704
+
3705
+ return id
3706
+ }
3707
+
3708
+ const updateToast = (id: string, detail: ToastDetail): void => {
3709
+ const toast = document.getElementById(id)
3710
+
3711
+ if (!(toast instanceof HTMLElement) || !toast.hasAttribute('data-ui-toast')) return
3712
+
3713
+ setToastContent(toast, { ...detail, id })
3714
+
3715
+ if (detail.duration !== undefined) {
3716
+ scheduleToastDismiss(toast, detail.duration)
3717
+ }
3718
+ }
3719
+
3720
+ const dismissToastById = (id?: string): void => {
3721
+ const selector = id ? `#${CSS.escape(id)}` : '[data-ui-toast]'
3722
+
3723
+ for (const toast of document.querySelectorAll<HTMLElement>(selector)) {
3724
+ if (toast.hasAttribute('data-ui-toast')) {
3725
+ dismissToast(toast)
3726
+ }
3727
+ }
3728
+ }
3729
+
3730
+ const api: ToastApi = {
3731
+ create: createToast,
3732
+ dismiss: dismissToastById,
3733
+ update: updateToast
3734
+ }
3735
+
3736
+ ;(window as Window & { LumenToast?: ToastApi }).LumenToast = api
3737
+
3738
+ for (const viewport of document.querySelectorAll<HTMLElement>('[data-ui-sonner]')) {
3739
+ viewport.dataset.placement = getToastPlacement(viewport.dataset.placement)
3740
+
3741
+ viewport.setAttribute('aria-live', viewport.getAttribute('aria-live') ?? 'polite')
3742
+
3743
+ viewport.setAttribute('aria-atomic', viewport.getAttribute('aria-atomic') ?? 'false')
3744
+ }
3745
+
3746
+ for (const toast of document.querySelectorAll<HTMLElement>('[data-ui-toast], .ui-toast')) {
3747
+ toast.addEventListener('keydown', event => {
3748
+ if (event.key !== 'Escape') return
3749
+
3750
+ event.preventDefault()
3751
+
3752
+ dismissToast(toast)
3753
+ })
3754
+ }
3755
+
3756
+ document.addEventListener('ui:toast', event => {
3757
+ createToast(event instanceof CustomEvent ? event.detail as ToastDetail : {})
3758
+ })
3759
+
3760
+ document.addEventListener('ui:toast-update', event => {
3761
+ const detail = event instanceof CustomEvent ? event.detail as ToastDetail : {}
3762
+
3763
+ if (detail.id) updateToast(detail.id, detail)
3764
+ })
3765
+
3766
+ document.addEventListener('ui:toast-dismiss', event => {
3767
+ const detail = event instanceof CustomEvent ? event.detail as { id?: string } : {}
3768
+
3769
+ dismissToastById(detail.id)
3770
+ })
3771
+ }
3772
+
3773
+ const initFileUploads = (scope: ParentNode): void => {
3774
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-file-upload]')) {
3775
+ if (root.dataset.uiBound === 'true') continue
3776
+
3777
+ const input = root.querySelector<HTMLInputElement>('[data-ui-file-upload-input]')
3778
+
3779
+ if (!input) continue
3780
+
3781
+ root.dataset.uiBound = 'true'
3782
+
3783
+ const files = root.querySelector<HTMLElement>('[data-ui-file-upload-files]')
3784
+
3785
+ const renderFiles = (): void => {
3786
+ const selectedFiles = input.files ? [...input.files] : []
3787
+
3788
+ root.dataset.state = selectedFiles.length > 0 ? 'selected' : 'idle'
3789
+
3790
+ if (!files) return
3791
+
3792
+ if (selectedFiles.length === 1) {
3793
+ files.textContent = selectedFiles[0]?.name ?? ''
3794
+ } else {
3795
+ files.textContent = selectedFiles.length > 1
3796
+ ? `${selectedFiles.length} files selected`
3797
+ : ''
3798
+ }
3799
+ }
3800
+
3801
+ input.addEventListener('change', renderFiles)
3802
+
3803
+ root.addEventListener('dragover', event => {
3804
+ if (input.disabled) return
3805
+
3806
+ event.preventDefault()
3807
+
3808
+ root.dataset.state = 'drag-over'
3809
+ })
3810
+
3811
+ root.addEventListener('dragleave', event => {
3812
+ if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return
3813
+
3814
+ root.dataset.state = input.files?.length ? 'selected' : 'idle'
3815
+ })
3816
+
3817
+ root.addEventListener('drop', event => {
3818
+ if (input.disabled) return
3819
+
3820
+ event.preventDefault()
3821
+
3822
+ if (event.dataTransfer) {
3823
+ input.files = event.dataTransfer.files
3824
+
3825
+ input.dispatchEvent(new Event('change', { bubbles: true }))
3826
+ }
3827
+ })
3828
+
3829
+ renderFiles()
3830
+ }
3831
+ }
3832
+
3833
+ const initAnchors = (scope: ParentNode): void => {
3834
+ if (typeof IntersectionObserver === 'undefined') return
3835
+
3836
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-anchor]')) {
3837
+ if (root.dataset.uiBound === 'true') continue
3838
+
3839
+ root.dataset.uiBound = 'true'
3840
+
3841
+ const links = [...root.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')]
3842
+ const byId = new Map<string, HTMLAnchorElement>()
3843
+ const targets: HTMLElement[] = []
3844
+
3845
+ for (const link of links) {
3846
+ const id = decodeURIComponent(link.getAttribute('href')?.slice(1) ?? '')
3847
+ const target = id ? document.getElementById(id) : null
3848
+
3849
+ if (target) {
3850
+ byId.set(id, link)
3851
+
3852
+ targets.push(target)
3853
+ }
3854
+ }
3855
+
3856
+ if (!targets.length) continue
3857
+
3858
+ const observer = new IntersectionObserver(entries => {
3859
+ for (const entry of entries) {
3860
+ if (!entry.isIntersecting) continue
3861
+
3862
+ for (const link of links) {
3863
+ link.dataset.active = 'false'
3864
+ }
3865
+
3866
+ const active = byId.get(entry.target.id)
3867
+
3868
+ if (active) {
3869
+ active.dataset.active = 'true'
3870
+ }
3871
+
3872
+ break
3873
+ }
3874
+ }, { rootMargin: '0px 0px -70% 0px', threshold: 0 })
3875
+
3876
+ for (const target of targets) {
3877
+ observer.observe(target)
3878
+ }
3879
+ }
3880
+ }
3881
+
3882
+ const initTours = (scope: ParentNode): void => {
3883
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-tour]')) {
3884
+ if (root.dataset.uiBound === 'true') continue
3885
+
3886
+ const popover = root.querySelector<HTMLElement>('[data-ui-tour-popover]')
3887
+ const steps = [...root.querySelectorAll<HTMLElement>('[data-ui-tour-step]')]
3888
+
3889
+ if (!popover || !steps.length) continue
3890
+
3891
+ root.dataset.uiBound = 'true'
3892
+
3893
+ let index = 0
3894
+
3895
+ const positionStep = (): void => {
3896
+ const step = steps[index]
3897
+
3898
+ if (!step) return
3899
+
3900
+ for (const [stepIndex, node] of steps.entries()) {
3901
+ node.hidden = stepIndex !== index
3902
+ }
3903
+
3904
+ const selector = step.dataset.target
3905
+ const target = selector ? document.querySelector<HTMLElement>(selector) : null
3906
+
3907
+ if (target) {
3908
+ const rect = target.getBoundingClientRect()
3909
+
3910
+ popover.style.top = `${rect.bottom + 8}px`
3911
+
3912
+ popover.style.left = `${rect.left}px`
3913
+ }
3914
+ }
3915
+
3916
+ const open = (): void => {
3917
+ root.hidden = false
3918
+
3919
+ index = 0
3920
+
3921
+ positionStep()
3922
+ }
3923
+
3924
+ const close = (): void => {
3925
+ root.hidden = true
3926
+ }
3927
+
3928
+ root.addEventListener('click', event => {
3929
+ const target = event.target
3930
+
3931
+ if (!(target instanceof HTMLElement)) return
3932
+
3933
+ if (target.closest('[data-ui-tour-next]')) {
3934
+ if (index < steps.length - 1) {
3935
+ index += 1
3936
+
3937
+ positionStep()
3938
+ } else {
3939
+ close()
3940
+ }
3941
+ } else if (target.closest('[data-ui-tour-prev]')) {
3942
+ if (index > 0) {
3943
+ index -= 1
3944
+
3945
+ positionStep()
3946
+ }
3947
+ } else if (target.closest('[data-ui-tour-close]') || target.hasAttribute('data-ui-tour-backdrop')) {
3948
+ close()
3949
+ }
3950
+ })
3951
+
3952
+ const opener = root.id ? document.querySelector<HTMLElement>(`[data-ui-tour-open="#${root.id}"]`) : null
3953
+
3954
+ if (opener) {
3955
+ opener.addEventListener('click', open)
3956
+ }
3957
+
3958
+ const lumenTourWindow = window as Window & { LumenTours?: Record<string, () => void> }
3959
+
3960
+ lumenTourWindow.LumenTours = lumenTourWindow.LumenTours ?? {}
3961
+
3962
+ if (root.id) {
3963
+ lumenTourWindow.LumenTours[root.id] = open
3964
+ }
3965
+ }
3966
+ }
3967
+
3968
+ const initTransfers = (scope: ParentNode): void => {
3969
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-transfer]')) {
3970
+ if (root.dataset.uiBound === 'true') continue
3971
+
3972
+ root.dataset.uiBound = 'true'
3973
+
3974
+ const lists = new Map<string, HTMLElement>()
3975
+
3976
+ for (const list of root.querySelectorAll<HTMLElement>('[data-ui-transfer-list]')) {
3977
+ if (list.dataset.side) {
3978
+ lists.set(list.dataset.side, list)
3979
+ }
3980
+ }
3981
+
3982
+ const move = (fromSide: string, toSide: string): void => {
3983
+ const from = lists.get(fromSide)
3984
+ const to = lists.get(toSide)
3985
+
3986
+ if (!from || !to) return
3987
+
3988
+ const moved: string[] = []
3989
+
3990
+ for (const checkbox of from.querySelectorAll<HTMLInputElement>('[data-ui-transfer-item]')) {
3991
+ if (!checkbox.checked) continue
3992
+
3993
+ const item = checkbox.closest<HTMLElement>('.ui-transfer__item')
3994
+
3995
+ if (item) {
3996
+ checkbox.checked = false
3997
+
3998
+ to.append(item)
3999
+
4000
+ moved.push(checkbox.value)
4001
+ }
4002
+ }
4003
+
4004
+ if (moved.length) {
4005
+ root.dispatchEvent(new CustomEvent('ui:transfer-change', {
4006
+ bubbles: true,
4007
+ detail: { from: fromSide, to: toSide, values: moved }
4008
+ }))
4009
+ }
4010
+ }
4011
+
4012
+ for (const button of root.querySelectorAll<HTMLButtonElement>('[data-ui-transfer-move]')) {
4013
+ button.addEventListener('click', () => {
4014
+ if (button.dataset.uiTransferMove === 'target') {
4015
+ move('source', 'target')
4016
+ } else if (button.dataset.uiTransferMove === 'source') {
4017
+ move('target', 'source')
4018
+ }
4019
+ })
4020
+ }
4021
+ }
4022
+ }
4023
+
4024
+ const initMentions = (scope: ParentNode): void => {
4025
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-mentions]')) {
4026
+ if (root.dataset.uiBound === 'true') continue
4027
+
4028
+ const input = root.querySelector<HTMLTextAreaElement>('[data-ui-mentions-input]')
4029
+ const list = root.querySelector<HTMLElement>('[data-ui-mentions-list]')
4030
+
4031
+ if (!input || !list) continue
4032
+
4033
+ root.dataset.uiBound = 'true'
4034
+
4035
+ const trigger = root.dataset.uiMentionsTrigger ?? '@'
4036
+ const options = [...list.querySelectorAll<HTMLButtonElement>('[data-ui-mentions-option]')]
4037
+
4038
+ const close = (): void => {
4039
+ list.hidden = true
4040
+ }
4041
+
4042
+ const filter = (): void => {
4043
+ const caret = input.selectionStart ?? input.value.length
4044
+ const match = new RegExp(`\\${trigger}(\\w*)$`).exec(input.value.slice(0, caret))
4045
+
4046
+ if (!match) {
4047
+ close()
4048
+
4049
+ return
4050
+ }
4051
+
4052
+ const query = (match[1] ?? '').toLowerCase()
4053
+ let visibleCount = 0
4054
+
4055
+ for (const option of options) {
4056
+ const visible = (option.dataset.value ?? '').toLowerCase().startsWith(query)
4057
+
4058
+ option.hidden = !visible
4059
+
4060
+ if (visible) visibleCount += 1
4061
+ }
4062
+
4063
+ list.hidden = visibleCount === 0
4064
+ }
4065
+
4066
+ const insert = (value: string): void => {
4067
+ const caret = input.selectionStart ?? input.value.length
4068
+ const before = input.value.slice(0, caret).replace(new RegExp(`\\${trigger}\\w*$`), `${trigger}${value} `)
4069
+
4070
+ input.value = before + input.value.slice(caret)
4071
+
4072
+ input.focus()
4073
+
4074
+ input.setSelectionRange(before.length, before.length)
4075
+
4076
+ close()
4077
+
4078
+ input.dispatchEvent(new Event('input', { bubbles: true }))
4079
+ }
4080
+
4081
+ input.addEventListener('input', filter)
4082
+
4083
+ input.addEventListener('keyup', filter)
4084
+
4085
+ input.addEventListener('blur', () => window.setTimeout(close, 120))
4086
+
4087
+ for (const option of options) {
4088
+ option.addEventListener('mousedown', event => {
4089
+ event.preventDefault()
4090
+
4091
+ insert(option.dataset.value ?? '')
4092
+ })
4093
+ }
4094
+ }
4095
+ }
4096
+
4097
+ const closeSpeedDial = (root: HTMLElement, restoreFocus = false): void => {
4098
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-speed-dial-trigger]')
4099
+
4100
+ root.dataset.state = 'closed'
4101
+
4102
+ trigger?.setAttribute('aria-expanded', 'false')
4103
+
4104
+ if (restoreFocus) trigger?.focus()
4105
+ }
4106
+
4107
+ const speedDialNavigationKeys = ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']
4108
+
4109
+ const initSpeedDials = (scope: ParentNode): void => {
4110
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-speed-dial]')) {
4111
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ui-speed-dial-trigger]')
4112
+ const actions = root.querySelector<HTMLElement>('[data-ui-speed-dial-actions]')
4113
+
4114
+ if (!trigger || !actions || root.dataset.uiBound === 'true') continue
4115
+
4116
+ const items = [...actions.querySelectorAll<HTMLElement>('[role="menuitem"], button:not([disabled]), a[href]')]
4117
+
4118
+ root.dataset.uiBound = 'true'
4119
+
4120
+ root.dataset.state = root.dataset.state === 'open' ? 'open' : 'closed'
4121
+
4122
+ trigger.setAttribute('aria-expanded', String(root.dataset.state === 'open'))
4123
+
4124
+ const open = (focusPosition?: 'first' | 'last') => {
4125
+ root.dataset.state = 'open'
4126
+
4127
+ trigger.setAttribute('aria-expanded', 'true')
4128
+
4129
+ if (focusPosition === 'first') items[0]?.focus()
4130
+
4131
+ if (focusPosition === 'last') items.at(-1)?.focus()
4132
+ }
4133
+
4134
+ trigger.addEventListener('click', () => {
4135
+ if (root.dataset.state === 'open') {
4136
+ closeSpeedDial(root)
4137
+ } else {
4138
+ open()
4139
+ }
4140
+ })
4141
+
4142
+ trigger.addEventListener('keydown', event => {
4143
+ if (event.key === 'Escape') closeSpeedDial(root)
4144
+
4145
+ if (['ArrowDown', 'ArrowRight'].includes(event.key)) {
4146
+ event.preventDefault()
4147
+
4148
+ open('first')
4149
+
4150
+ return
4151
+ }
4152
+
4153
+ if (['ArrowLeft', 'ArrowUp'].includes(event.key)) {
4154
+ event.preventDefault()
4155
+
4156
+ open('last')
4157
+ }
4158
+ })
4159
+
4160
+ actions.addEventListener('click', event => {
4161
+ if ((event.target as HTMLElement).closest('[role="menuitem"], button, a')) {
4162
+ closeSpeedDial(root)
4163
+ }
4164
+ })
4165
+
4166
+ actions.addEventListener('keydown', event => {
4167
+ if (event.key === 'Escape') {
4168
+ event.preventDefault()
4169
+
4170
+ closeSpeedDial(root, true)
4171
+
4172
+ return
4173
+ }
4174
+
4175
+ if (!speedDialNavigationKeys.includes(event.key)) return
4176
+
4177
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement)
4178
+
4179
+ const targetIndex = getLoopedIndex(
4180
+ event.key,
4181
+ currentIndex,
4182
+ items.length,
4183
+ ['ArrowDown', 'ArrowRight']
4184
+ )
4185
+
4186
+ event.preventDefault()
4187
+
4188
+ items[targetIndex]?.focus()
4189
+ })
4190
+ }
4191
+ }
4192
+
4193
+ // cspell:disable-next-line
4194
+ const initCascaders = (scope: ParentNode): void => {
4195
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-cascader]')) {
4196
+ if (root.dataset.uiCascaderBound === 'true') continue
4197
+
4198
+ root.dataset.uiCascaderBound = 'true'
4199
+
4200
+ const valueLabel = root.querySelector<HTMLElement>('[data-ui-cascader-value]')
4201
+ const input = root.querySelector<HTMLInputElement>('[data-ui-cascader-input]')
4202
+ const trigger = root.querySelector<HTMLElement>('[data-ui-trigger]')
4203
+ const panel = trigger ? getPanel(trigger) : null
4204
+ const columns = [...root.querySelectorAll<HTMLElement>('.ui-cascader__column')]
4205
+
4206
+ for (const option of root.querySelectorAll<HTMLButtonElement>('[data-ui-cascader-option]')) {
4207
+ option.addEventListener('click', () => {
4208
+ const column = option.closest<HTMLElement>('.ui-cascader__column')
4209
+
4210
+ if (column) {
4211
+ const columnIndex = columns.indexOf(column)
4212
+
4213
+ for (const descendant of columns.slice(columnIndex + 1)) {
4214
+ descendant.hidden = true
4215
+
4216
+ for (const descendantOption of descendant.querySelectorAll<HTMLElement>('[data-ui-cascader-option]')) {
4217
+ descendantOption.dataset.active = 'false'
4218
+
4219
+ descendantOption.setAttribute('aria-selected', 'false')
4220
+ }
4221
+ }
4222
+
4223
+ for (const sibling of column.querySelectorAll<HTMLElement>('[data-ui-cascader-option]')) {
4224
+ sibling.dataset.active = 'false'
4225
+
4226
+ sibling.setAttribute('aria-selected', 'false')
4227
+ }
4228
+ }
4229
+
4230
+ option.dataset.active = 'true'
4231
+
4232
+ option.setAttribute('aria-selected', 'true')
4233
+
4234
+ const nextSelector = option.dataset.uiCascaderNext
4235
+ const next = nextSelector ? root.querySelector<HTMLElement>(nextSelector) : null
4236
+
4237
+ if (next) {
4238
+ next.hidden = false
4239
+
4240
+ return
4241
+ }
4242
+
4243
+ const value = option.dataset.value ?? (option.textContent ?? '').trim()
4244
+
4245
+ if (valueLabel) {
4246
+ valueLabel.textContent = option.dataset.label ?? (option.textContent ?? '').trim()
4247
+ }
4248
+
4249
+ if (input) {
4250
+ input.value = value
4251
+ }
4252
+
4253
+ if (trigger && panel) {
4254
+ setOpen(trigger, panel, false)
4255
+ }
4256
+
4257
+ root.dispatchEvent(new CustomEvent('ui:cascader-change', { bubbles: true, detail: { value } }))
4258
+ })
4259
+ }
4260
+
4261
+ panel?.addEventListener('keydown', event => {
4262
+ const target = event.target
4263
+
4264
+ if (!(target instanceof HTMLElement)) return
4265
+
4266
+ const option = target.closest<HTMLButtonElement>('[data-ui-cascader-option]')
4267
+ const column = option?.closest<HTMLElement>('.ui-cascader__column')
4268
+
4269
+ if (!option || !column) return
4270
+
4271
+ const columnIndex = columns.indexOf(column)
4272
+ const options = [...column.querySelectorAll<HTMLButtonElement>('[data-ui-cascader-option]')]
4273
+
4274
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
4275
+ event.preventDefault()
4276
+
4277
+ const currentIndex = options.indexOf(option)
4278
+ const nextIndex = getLoopedIndex(event.key, currentIndex, options.length, ['ArrowDown'])
4279
+
4280
+ options[nextIndex]?.focus()
4281
+
4282
+ return
4283
+ }
4284
+
4285
+ if (event.key === 'ArrowRight') {
4286
+ const nextSelector = option.dataset.uiCascaderNext
4287
+ const next = nextSelector ? root.querySelector<HTMLElement>(nextSelector) : null
4288
+
4289
+ if (!next) return
4290
+
4291
+ event.preventDefault()
4292
+
4293
+ option.click()
4294
+
4295
+ next.querySelector<HTMLElement>('[data-ui-cascader-option]')?.focus()
4296
+
4297
+ return
4298
+ }
4299
+
4300
+ if (event.key !== 'ArrowLeft' || columnIndex <= 0) return
4301
+
4302
+ event.preventDefault()
4303
+
4304
+ const previousColumn = columns.findLast((candidate, index) => index < columnIndex && !candidate.hidden)
4305
+
4306
+ for (const descendant of columns.slice(columnIndex)) {
4307
+ descendant.hidden = true
4308
+ }
4309
+
4310
+ previousColumn?.querySelector<HTMLElement>('[data-ui-cascader-option][data-active="true"]')?.focus()
4311
+ })
4312
+ }
4313
+ }
4314
+
4315
+ const initTreeSelects = (scope: ParentNode): void => {
4316
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-tree-select]')) {
4317
+ if (root.dataset.uiTreeSelectBound === 'true') continue
4318
+
4319
+ root.dataset.uiTreeSelectBound = 'true'
4320
+
4321
+ const valueLabel = root.querySelector<HTMLElement>('[data-ui-tree-select-value]')
4322
+ const input = root.querySelector<HTMLInputElement>('[data-ui-tree-select-input]')
4323
+ const trigger = root.querySelector<HTMLElement>('[data-ui-trigger]')
4324
+ const panel = root.querySelector<HTMLElement>('[data-ui-panel]')
4325
+
4326
+ for (const item of root.querySelectorAll<HTMLElement>('[data-ui-panel] [data-value]')) {
4327
+ item.addEventListener('click', () => {
4328
+ const value = item.dataset.value ?? ''
4329
+
4330
+ if (valueLabel) {
4331
+ valueLabel.textContent = (item.textContent ?? '').trim()
4332
+ }
4333
+
4334
+ if (input) {
4335
+ input.value = value
4336
+ }
4337
+
4338
+ if (trigger && panel) {
4339
+ setOpen(trigger, panel, false)
4340
+ }
4341
+
4342
+ root.dispatchEvent(new CustomEvent('ui:tree-select-change', { bubbles: true, detail: { value } }))
4343
+ })
4344
+ }
4345
+ }
4346
+ }
4347
+
4348
+ const initBackToTopButtons = (scope: ParentNode): void => {
4349
+ for (const button of scope.querySelectorAll<HTMLButtonElement>('[data-ui-back-to-top]')) {
4350
+ if (button.dataset.uiBound === 'true') continue
4351
+
4352
+ button.dataset.uiBound = 'true'
4353
+
4354
+ button.addEventListener('click', () => {
4355
+ window.scrollTo({
4356
+ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
4357
+ top: 0
4358
+ })
4359
+ })
4360
+ }
4361
+ }
4362
+
4363
+ const initScrollReveals = (scope: ParentNode): void => {
4364
+ for (const root of scope.querySelectorAll<HTMLElement>(
4365
+ '[data-ui-scroll-reveal], [data-ui-reveal-group]'
4366
+ )) {
4367
+ if (root.dataset.uiScrollRevealBound === 'true') continue
4368
+
4369
+ root.dataset.uiScrollRevealBound = 'true'
4370
+
4371
+ if (root.matches('[data-ui-reveal-group]')) {
4372
+ for (const [index, child] of [...root.children].entries()) {
4373
+ if (child instanceof HTMLElement) {
4374
+ child.style.setProperty('--ui-reveal-index', String(index))
4375
+ }
4376
+ }
4377
+ }
4378
+
4379
+ if (
4380
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
4381
+ || typeof IntersectionObserver === 'undefined'
4382
+ ) {
4383
+ root.classList.add('is-revealed')
4384
+
4385
+ continue
4386
+ }
4387
+
4388
+ const once = root.dataset.uiRevealOnce !== 'false'
4389
+
4390
+ const threshold = Math.min(
4391
+ 1,
4392
+ Math.max(0, Number(root.dataset.uiRevealThreshold) || 0)
4393
+ )
4394
+
4395
+ const observer = new IntersectionObserver((entries) => {
4396
+ const entry = entries[0]
4397
+
4398
+ if (!entry) return
4399
+
4400
+ root.classList.toggle('is-revealed', entry.isIntersecting)
4401
+
4402
+ if (entry.isIntersecting && once) observer.disconnect()
4403
+ }, { threshold })
4404
+
4405
+ observer.observe(root)
4406
+ }
4407
+ }
4408
+
4409
+ const initAnimatedNumbers = (scope: ParentNode): void => {
4410
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-animated-number]')) {
4411
+ if (root.dataset.uiBound === 'true') continue
4412
+
4413
+ const output = root.querySelector<HTMLElement>('[data-ui-animated-number-output]')
4414
+ const from = Number(root.dataset.uiAnimatedNumberFrom)
4415
+ const value = Number(root.dataset.uiAnimatedNumberValue)
4416
+
4417
+ if (!output || !Number.isFinite(from) || !Number.isFinite(value)) continue
4418
+
4419
+ root.dataset.uiBound = 'true'
4420
+
4421
+ const decimals = Math.max(
4422
+ 0,
4423
+ Math.min(20, Number(root.dataset.uiAnimatedNumberDecimals) || 0)
4424
+ )
4425
+
4426
+ const prefix = root.dataset.uiAnimatedNumberPrefix ?? ''
4427
+ const suffix = root.dataset.uiAnimatedNumberSuffix ?? ''
4428
+
4429
+ const formatter = new Intl.NumberFormat(root.dataset.uiAnimatedNumberLocale || undefined, {
4430
+ maximumFractionDigits: decimals,
4431
+ minimumFractionDigits: decimals
4432
+ })
4433
+
4434
+ const format = (current: number): string =>
4435
+ `${prefix}${formatter.format(current)}${suffix}`
4436
+
4437
+ if (
4438
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
4439
+ || typeof IntersectionObserver === 'undefined'
4440
+ ) {
4441
+ output.textContent = format(value)
4442
+
4443
+ continue
4444
+ }
4445
+
4446
+ output.textContent = format(from)
4447
+
4448
+ const observer = new IntersectionObserver((entries) => {
4449
+ if (!entries.some(entry => entry.isIntersecting)) return
4450
+
4451
+ observer.disconnect()
4452
+
4453
+ const durationValue = getComputedStyle(root)
4454
+ .getPropertyValue('--ui-motion-duration')
4455
+ .trim()
4456
+
4457
+ const parsedDuration = Number.parseFloat(durationValue)
4458
+ let durationMs = 300
4459
+
4460
+ if (Number.isFinite(parsedDuration)) {
4461
+ durationMs = durationValue.endsWith('ms')
4462
+ ? parsedDuration
4463
+ : parsedDuration * 1000
4464
+ }
4465
+
4466
+ const startedAt = performance.now()
4467
+
4468
+ const update = (now: number): void => {
4469
+ const progress = Math.min(1, (now - startedAt) / Math.max(1, durationMs))
4470
+ const eased = 1 - Math.pow(1 - progress, 3)
4471
+
4472
+ output.textContent = format(from + ((value - from) * eased))
4473
+
4474
+ if (progress < 1) requestAnimationFrame(update)
4475
+ }
4476
+
4477
+ requestAnimationFrame(update)
4478
+ }, { threshold: 0.15 })
4479
+
4480
+ observer.observe(root)
4481
+ }
4482
+ }
4483
+
4484
+ const initUiPrimitives = (scope: ParentNode = document): void => {
4485
+ initDialogs(scope)
4486
+
4487
+ initFields(scope)
4488
+
4489
+ initForms(scope)
4490
+
4491
+ initDisclosures(scope)
4492
+
4493
+ initTabs(scope)
4494
+
4495
+ initRovingGroups(scope)
4496
+
4497
+ initToggles(scope)
4498
+
4499
+ initCommand(scope)
4500
+
4501
+ initComboboxes(scope)
4502
+
4503
+ initSelects(scope)
4504
+
4505
+ initResizableGroups(scope)
4506
+
4507
+ initInputOtpFields(scope)
4508
+
4509
+ initDataTables(scope)
4510
+
4511
+ initCalendars(scope)
4512
+
4513
+ initDatePickers(scope)
4514
+
4515
+ initCodeCopy(scope)
4516
+
4517
+
4518
+ initCarousel(scope)
4519
+
4520
+ initSchedules(scope)
4521
+
4522
+ initTreeCollections(scope)
4523
+
4524
+ initVirtualLists(scope)
4525
+
4526
+ initTagGroups(scope)
4527
+
4528
+ initRichTextEditors(scope)
4529
+
4530
+ initThemeBuilders(scope)
4531
+
4532
+ initTooltips(scope)
4533
+
4534
+ initContextMenus(scope)
4535
+
4536
+ initDateRangePickers(scope)
4537
+
4538
+ initGlassTracking(scope)
4539
+
4540
+ initFileUploads(scope)
4541
+
4542
+ initAnchors(scope)
4543
+
4544
+ initTours(scope)
4545
+
4546
+ initTransfers(scope)
4547
+
4548
+ initMentions(scope)
4549
+
4550
+ initSpeedDials(scope)
4551
+
4552
+ // cspell:disable-next-line
4553
+ initCascaders(scope)
4554
+
4555
+ initTreeSelects(scope)
4556
+
4557
+ initBackToTopButtons(scope)
4558
+
4559
+ initScrollReveals(scope)
4560
+
4561
+ initAnimatedNumbers(scope)
4562
+
4563
+ initToasts()
4564
+ }
4565
+
4566
+ const lumenWindow = window as Window & { LumenInitUiPrimitives?: (scope?: ParentNode) => void }
4567
+
4568
+ lumenWindow.LumenInitUiPrimitives = initUiPrimitives
4569
+
4570
+ if (document.documentElement.dataset.uiPrimitivesGlobalBound !== 'true') {
4571
+ document.documentElement.dataset.uiPrimitivesGlobalBound = 'true'
4572
+
4573
+ document.addEventListener('click', event => {
4574
+ const target = event.target
4575
+
4576
+ if (!(target instanceof Node)) return
4577
+
4578
+ for (const root of document.querySelectorAll<HTMLElement>(disclosureSelector)) {
4579
+ if (!root.contains(target)) {
4580
+ closeDisclosure(root)
4581
+ }
4582
+ }
4583
+
4584
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
4585
+ if (!root.contains(target)) {
4586
+ closeCombobox(root)
4587
+ }
4588
+ }
4589
+
4590
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-select]')) {
4591
+ if (!root.contains(target)) {
4592
+ closeSelect(root)
4593
+ }
4594
+ }
4595
+
4596
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
4597
+ if (!root.contains(target)) {
4598
+ closeDatePicker(root)
4599
+ }
4600
+ }
4601
+
4602
+ for (const menu of document.querySelectorAll<HTMLElement>('[data-ui-context-menu][data-state="open"]')) {
4603
+ if (!menu.contains(target)) {
4604
+ closeContextMenu(menu)
4605
+ }
4606
+ }
4607
+
4608
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-speed-dial][data-state="open"]')) {
4609
+ if (!root.contains(target)) {
4610
+ closeSpeedDial(root)
4611
+ }
4612
+ }
4613
+ })
4614
+
4615
+ document.addEventListener('keydown', event => {
4616
+ if (event.key !== 'Escape') return
4617
+
4618
+ for (const root of document.querySelectorAll<HTMLElement>(disclosureSelector)) {
4619
+ closeDisclosure(root)
4620
+ }
4621
+
4622
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
4623
+ closeCombobox(root)
4624
+ }
4625
+
4626
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-select]')) {
4627
+ closeSelect(root)
4628
+ }
4629
+
4630
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
4631
+ closeDatePicker(root, root.contains(document.activeElement))
4632
+ }
4633
+
4634
+ for (const menu of document.querySelectorAll<HTMLElement>('[data-ui-context-menu][data-state="open"]')) {
4635
+ closeContextMenu(menu)
4636
+ }
4637
+
4638
+ for (const root of document.querySelectorAll<HTMLElement>('[data-ui-speed-dial][data-state="open"]')) {
4639
+ closeSpeedDial(root, root.contains(document.activeElement))
4640
+ }
4641
+ })
4642
+
4643
+ document.addEventListener('astro:page-load', () => lumenWindow.LumenInitUiPrimitives?.())
4644
+
4645
+ document.addEventListener('astro:after-swap', () => lumenWindow.LumenInitUiPrimitives?.())
4646
+ }
4647
+
4648
+ initUiPrimitives()
4649
+ </script>