@santi020k/lumen-astro 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +403 -7
  2. package/components/AmountField.astro +21 -0
  3. package/components/AnimatedNumber.astro +1 -1
  4. package/components/ApprovalCard.astro +30 -0
  5. package/components/AttachmentList.astro +15 -0
  6. package/components/AttachmentPreview.astro +51 -0
  7. package/components/Avatar.astro +2 -0
  8. package/components/BarChart.astro +95 -31
  9. package/components/BoxPlot.astro +77 -0
  10. package/components/Breadcrumb.astro +2 -1
  11. package/components/BulletChart.astro +64 -0
  12. package/components/Button.astro +1 -1
  13. package/components/Calendar.astro +54 -65
  14. package/components/CalendarHeatmap.astro +76 -0
  15. package/components/Card.astro +4 -0
  16. package/components/ChangeSummary.astro +36 -0
  17. package/components/ChartDatumActions.astro +24 -0
  18. package/components/ChartMotion.astro +11 -0
  19. package/components/Code.astro +18 -3
  20. package/components/CodeTabs.astro +12 -0
  21. package/components/ComboChart.astro +93 -11
  22. package/components/CountryFlag.astro +23 -0
  23. package/components/DataTable.astro +15 -7
  24. package/components/DatePicker.astro +24 -17
  25. package/components/DescriptionDetail.astro +18 -0
  26. package/components/DescriptionItem.astro +18 -0
  27. package/components/DescriptionTerm.astro +18 -0
  28. package/components/DeviceFrame.astro +79 -0
  29. package/components/DialogBody.astro +20 -0
  30. package/components/DialogClose.astro +21 -0
  31. package/components/DialogFooter.astro +20 -0
  32. package/components/DialogHeader.astro +20 -0
  33. package/components/DialogTitle.astro +21 -0
  34. package/components/DumbbellChart.astro +8 -0
  35. package/components/FileUpload.astro +3 -1
  36. package/components/FilterBar.astro +25 -0
  37. package/components/FunnelChart.astro +50 -0
  38. package/components/Grid.astro +2 -2
  39. package/components/Heatmap.astro +61 -12
  40. package/components/Histogram.astro +30 -0
  41. package/components/Icon.astro +7 -3
  42. package/components/ImageComparison.astro +74 -0
  43. package/components/Input.astro +2 -2
  44. package/components/LineChart.astro +127 -67
  45. package/components/LollipopChart.astro +8 -0
  46. package/components/MediaFilmstrip.astro +19 -0
  47. package/components/MediaThumbnail.astro +29 -0
  48. package/components/MediaViewport.astro +38 -0
  49. package/components/Mentions.astro +2 -1
  50. package/components/MessageScroller.astro +6 -0
  51. package/components/MotionGroup.astro +19 -0
  52. package/components/NativeSelect.astro +2 -2
  53. package/components/Pagination.astro +2 -1
  54. package/components/PasswordField.astro +1 -1
  55. package/components/PhoneInput.astro +60 -9
  56. package/components/PhoneNumber.astro +19 -0
  57. package/components/PieChart.astro +26 -3
  58. package/components/PromptComposer.astro +36 -0
  59. package/components/RangeChart.astro +38 -8
  60. package/components/ScatterChart.astro +62 -14
  61. package/components/Segmented.astro +5 -5
  62. package/components/Select.astro +8 -8
  63. package/components/SourceCitation.astro +18 -0
  64. package/components/Sparkline.astro +9 -10
  65. package/components/Stack.astro +2 -2
  66. package/components/StreamMessage.astro +26 -0
  67. package/components/Tabs.astro +3 -0
  68. package/components/ToolActivity.astro +17 -0
  69. package/components/TreeSelect.astro +1 -0
  70. package/components/VirtualList.astro +5 -2
  71. package/components/VisualEffect.astro +21 -0
  72. package/components/WaterfallChart.astro +27 -0
  73. package/components/WorldMap.astro +213 -0
  74. package/forms.ts +17 -4
  75. package/index.ts +37 -0
  76. package/internal/ChartInspection.astro +27 -0
  77. package/internal/ComparisonChart.astro +69 -0
  78. package/internal/IntervalChart.astro +66 -0
  79. package/package.json +6 -4
  80. package/runtime/UIPrimitives.astro +670 -647
  81. package/runtime/chart-actions.ts +19 -0
  82. package/runtime/controllers/amount-field.ts +12 -0
  83. package/runtime/controllers/attachments.ts +13 -0
  84. package/runtime/controllers/chart-activation.ts +33 -0
  85. package/runtime/controllers/charts.ts +33 -0
  86. package/runtime/controllers/combobox.ts +39 -0
  87. package/runtime/controllers/comparison-reset.ts +20 -0
  88. package/runtime/controllers/data-table.ts +142 -0
  89. package/runtime/controllers/dialogs.ts +32 -32
  90. package/runtime/controllers/document-navigation.ts +54 -22
  91. package/runtime/controllers/file-upload.ts +89 -0
  92. package/runtime/controllers/image-comparison.ts +53 -0
  93. package/runtime/controllers/media-selection.ts +20 -0
  94. package/runtime/controllers/media-viewport.ts +54 -0
  95. package/runtime/controllers/media-workspace.ts +8 -0
  96. package/runtime/controllers/message-scroller.ts +13 -0
  97. package/runtime/controllers/motion.ts +15 -16
  98. package/runtime/controllers/optional-media.ts +29 -0
  99. package/runtime/controllers/phone-input.ts +128 -39
  100. package/runtime/controllers/visual-interactions.ts +75 -0
  101. package/runtime/controllers/world-map.ts +154 -0
  102. package/styles/critical.css +634 -61
  103. package/styles/lumen.css +101 -0
@@ -9,13 +9,18 @@
9
9
  ---
10
10
 
11
11
  <script>
12
+ import { isLumenDateBoundsValid, parseLumenDate as parseCalendarDate, resolveLumenDateLabels, resolveLumenDateLocale } from '@santi020k/lumen-core'
12
13
  import {
14
+ coerceThemePreset,
13
15
  createLumenKanbanMoveDetail,
14
- createThemeFromHue,
16
+ createLumenVirtualListController,
17
+ createThemeBuilderTokens,
18
+ executeLumenRichTextCommand,
15
19
  exportThemeCss,
16
20
  exportThemeDesignTokens,
17
21
  exportThemeFigmaVariables,
18
22
  formatLumenLanguageLabel,
23
+ getLumenDirectionalKey,
19
24
  getLumenLocalePair,
20
25
  getLumenRichTextShortcut,
21
26
  isLumenRichTextToggleCommand,
@@ -28,6 +33,12 @@ import {
28
33
  normalizeLumenLocales,
29
34
  scrollLumenTabIntoView } from '@santi020k/lumen-core'
30
35
 
36
+ import { initAmountFields } from './controllers/amount-field'
37
+ import { clearComboboxes, closeCombobox, initComboboxes } from './controllers/combobox'
38
+ import { initDataTableSorting } from './controllers/data-table'
39
+ import { initMessageFeeds } from './controllers/message-scroller'
40
+ import { initOptionalMediaControllers } from './controllers/optional-media'
41
+
31
42
  /* eslint-disable complexity */
32
43
  /* Stateful DOM handlers stay local so Astro consumers do not need a framework runtime. */
33
44
  type ThemeBuilderExportFormat = 'css' | 'figma' | 'tokens'
@@ -67,12 +78,6 @@ const fieldControlSelector = [
67
78
  const fieldDescriptionSelector = '[data-ui-field-hint], [data-ui-field-error], .ui-field__hint, .ui-field__error'
68
79
  const fieldErrorSelector = '[data-ui-field-error], .ui-field__error'
69
80
 
70
- const formControlSelector = [
71
- 'input:not([type="hidden"])',
72
- 'select',
73
- 'textarea'
74
- ].join(',')
75
-
76
81
  const validityMessageAttributes = [
77
82
  ['valueMissing', 'data-error-required'],
78
83
  ['typeMismatch', 'data-error-type'],
@@ -99,16 +104,36 @@ const supportsAnchoredPopovers =
99
104
  CSS.supports('anchor-name: --ui-anchor') &&
100
105
  CSS.supports('position-area: block-end')
101
106
 
107
+ let uiGeneratedId = 0
108
+
109
+ const nextRuntimeId = (prefix: string, ownerDocument: Document): string => {
110
+ let id: string
111
+
112
+ do {
113
+ id = `${prefix}-${++uiGeneratedId}`
114
+ } while (ownerDocument.getElementById(id))
115
+
116
+ return id
117
+ }
118
+
102
119
  let uiAnchorCount = 0
103
120
  let uiTabsCount = 0
104
121
  const nextAnchorName = (): string => `--ui-anchor-${++uiAnchorCount}`
105
122
 
106
- const isElementVisible = (element: HTMLElement): boolean => typeof element.checkVisibility === 'function' ?
107
- element.checkVisibility() :
108
- element.offsetParent !== null
123
+ const isElementVisible = (element: HTMLElement): boolean => {
124
+ if (typeof element.checkVisibility === 'function') {
125
+ return element.checkVisibility({ visibilityProperty: true })
126
+ }
127
+
128
+ const visibility = getComputedStyle(element).visibility
129
+
130
+ if (visibility === 'hidden' || visibility === 'collapse') return false
131
+
132
+ return element.offsetParent !== null || element.getClientRects().length > 0
133
+ }
109
134
 
110
135
  const getFocusable = (root: ParentNode): HTMLElement[] => [...root.querySelectorAll<HTMLElement>(focusableSelector)]
111
- .filter(element => !element.hasAttribute('hidden') && isElementVisible(element))
136
+ .filter(element => !element.matches(':disabled') && !element.closest('[hidden], [inert]') && isElementVisible(element))
112
137
 
113
138
  const getPanel = (trigger: HTMLElement): HTMLElement | null => {
114
139
  const controls = trigger.getAttribute('aria-controls')
@@ -206,6 +231,16 @@ const initDisclosures = (scope: ParentNode): void => {
206
231
  })
207
232
 
208
233
  trigger.addEventListener('keydown', event => {
234
+ if (event.defaultPrevented || event.isComposing) return
235
+
236
+ if (event.key === 'Escape' && trigger.getAttribute('aria-expanded') === 'true') {
237
+ event.preventDefault()
238
+
239
+ setOpen(trigger, panel, false)
240
+
241
+ return
242
+ }
243
+
209
244
  if (event.key !== 'ArrowDown' && event.key !== 'Enter' && event.key !== ' ') return
210
245
 
211
246
  event.preventDefault()
@@ -216,6 +251,8 @@ const initDisclosures = (scope: ParentNode): void => {
216
251
  })
217
252
 
218
253
  panel.addEventListener('keydown', event => {
254
+ if (event.defaultPrevented || event.isComposing) return
255
+
219
256
  if (event.key === 'Escape') {
220
257
  event.preventDefault()
221
258
 
@@ -226,6 +263,8 @@ const initDisclosures = (scope: ParentNode): void => {
226
263
  return
227
264
  }
228
265
 
266
+ if (event.target instanceof Element && event.target.matches('input, textarea, [contenteditable="true"]')) return
267
+
229
268
  if (root.hasAttribute('data-ui-cascader')) return
230
269
 
231
270
  if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
@@ -255,9 +294,31 @@ const initDisclosures = (scope: ParentNode): void => {
255
294
  }
256
295
 
257
296
  if (root.hasAttribute('data-ui-hover-card')) {
297
+ const containsFocusTarget = (target: EventTarget | null): boolean => {
298
+ try {
299
+ return Reflect.apply(root.contains, root, [target]) === true
300
+ } catch {
301
+ return false
302
+ }
303
+ }
304
+
258
305
  root.addEventListener('mouseenter', () => setOpen(trigger, panel, true))
259
306
 
260
- root.addEventListener('mouseleave', () => setOpen(trigger, panel, false))
307
+ root.addEventListener('mouseleave', () => {
308
+ if (!root.contains(root.ownerDocument.activeElement)) setOpen(trigger, panel, false)
309
+ })
310
+
311
+ root.addEventListener('focusin', event => {
312
+ if (containsFocusTarget(event.relatedTarget)) return
313
+
314
+ setOpen(trigger, panel, true)
315
+ })
316
+
317
+ root.addEventListener('focusout', event => {
318
+ if (containsFocusTarget(event.relatedTarget)) return
319
+
320
+ setOpen(trigger, panel, false)
321
+ })
261
322
  }
262
323
  }
263
324
  }
@@ -287,7 +348,7 @@ const initTooltips = (scope: ParentNode): void => {
287
348
 
288
349
  if (trigger && !trigger.closest('[role="tooltip"]')) {
289
350
  if (!tip.id) {
290
- tip.id = `ui-tooltip-${crypto.randomUUID()}`
351
+ tip.id = nextRuntimeId('ui-tooltip', tip.ownerDocument)
291
352
  }
292
353
 
293
354
  const describedBy = new Set((trigger.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean))
@@ -339,12 +400,22 @@ const initTooltips = (scope: ParentNode): void => {
339
400
  root.addEventListener('focusout', hide)
340
401
 
341
402
  root.addEventListener('keydown', event => {
342
- if (event.key === 'Escape') hide()
403
+ if (event.defaultPrevented || event.isComposing) return
404
+
405
+ if (event.key === 'Escape' && tip.matches(':popover-open')) {
406
+ event.preventDefault()
407
+
408
+ hide()
409
+ }
343
410
  })
344
411
  } else {
345
412
  /* WCAG 1.4.13: hover/focus tooltips must be dismissible with Escape. */
346
413
  root.addEventListener('keydown', event => {
347
- if (event.key !== 'Escape') return
414
+ if (event.defaultPrevented || event.isComposing) return
415
+
416
+ if (event.key !== 'Escape' || getComputedStyle(tip).visibility === 'hidden') return
417
+
418
+ event.preventDefault()
348
419
 
349
420
  tip.style.visibility = 'hidden'
350
421
  })
@@ -386,13 +457,25 @@ const initGlassTracking = (scope: ParentNode): void => {
386
457
  }
387
458
  }
388
459
 
460
+ const isFormElement = (element: EventTarget | null): element is Element => {
461
+ try {
462
+ return element !== null && Element.prototype.matches.call(element, '*')
463
+ } catch {
464
+ return false
465
+ }
466
+ }
467
+
468
+ const isFormHtmlElement = (element: EventTarget | null): element is HTMLElement => (
469
+ isFormElement(element) && element.namespaceURI === 'http://www.w3.org/1999/xhtml'
470
+ )
471
+
389
472
  const getFieldControl = (root: HTMLElement): HTMLElement | null => {
390
473
  const controlId = root.dataset.uiFieldControl
391
474
 
392
475
  if (controlId) {
393
- const control = document.getElementById(controlId)
476
+ const control = root.ownerDocument.getElementById(controlId)
394
477
 
395
- if (control instanceof HTMLElement) return control
478
+ if (isFormHtmlElement(control)) return control
396
479
  }
397
480
 
398
481
  return root.querySelector<HTMLElement>(fieldControlSelector)
@@ -400,7 +483,7 @@ const getFieldControl = (root: HTMLElement): HTMLElement | null => {
400
483
 
401
484
  const getFieldDescriptionId = (element: HTMLElement): string => {
402
485
  if (!element.id) {
403
- element.id = `ui-field-description-${crypto.randomUUID()}`
486
+ element.id = nextRuntimeId('ui-field-description', element.ownerDocument)
404
487
  }
405
488
 
406
489
  return element.id
@@ -413,9 +496,7 @@ const getFieldRoot = (control: HTMLElement): HTMLElement | null => (
413
496
  type NativeFormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
414
497
 
415
498
  const isNativeFormControl = (element: EventTarget | null): element is NativeFormControl => (
416
- element instanceof HTMLInputElement ||
417
- element instanceof HTMLSelectElement ||
418
- element instanceof HTMLTextAreaElement
499
+ isFormHtmlElement(element) && element.matches('input, select, textarea')
419
500
  )
420
501
 
421
502
  const getValidationMessage = (
@@ -483,8 +564,9 @@ const validateControl = (
483
564
  }
484
565
 
485
566
  const getFormControls = (form: HTMLFormElement): NativeFormControl[] => [
486
- ...form.querySelectorAll<NativeFormControl>(formControlSelector)
487
- ].filter(control => control.form === form && !control.disabled)
567
+ ...form.elements
568
+ ].filter(isNativeFormControl).filter(control => !control.disabled &&
569
+ control.type !== 'hidden')
488
570
 
489
571
  const validateForm = (form: HTMLFormElement): Array<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> => {
490
572
  const invalidControls: Array<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = []
@@ -522,8 +604,35 @@ const initFields = (scope: ParentNode): void => {
522
604
  }
523
605
  }
524
606
 
607
+ const formValidationDocuments = new WeakSet<Document>()
608
+
609
+ const observeFormValidation = (ownerDocument: Document): void => {
610
+ if (formValidationDocuments.has(ownerDocument)) return
611
+
612
+ formValidationDocuments.add(ownerDocument)
613
+
614
+ ownerDocument.addEventListener('focusout', event => {
615
+ const control = event.target
616
+
617
+ if (!isNativeFormControl(control)) return
618
+
619
+ const form = control.form
620
+
621
+ if (!form || form.dataset.uiFormBound !== 'true') return
622
+
623
+ const valid = validateControl(control, form)
624
+
625
+ form.dispatchEvent(new CustomEvent(valid ? 'ui:valid' : 'ui:invalid', {
626
+ bubbles: true,
627
+ detail: valid ? { control, form } : { control, controls: [control], form }
628
+ }))
629
+ })
630
+ }
631
+
525
632
  const initForms = (scope: ParentNode): void => {
526
633
  for (const form of scope.querySelectorAll<HTMLFormElement>('form[data-ui-form]')) {
634
+ observeFormValidation(form.ownerDocument)
635
+
527
636
  if (form.dataset.uiFormBound === 'true') continue
528
637
 
529
638
  form.dataset.uiFormBound = 'true'
@@ -540,13 +649,14 @@ const initForms = (scope: ParentNode): void => {
540
649
  form.addEventListener('click', event => {
541
650
  const target = event.target
542
651
 
543
- if (!(target instanceof Element)) return
652
+ if (!isFormElement(target)) return
544
653
 
545
654
  const link = target.closest<HTMLAnchorElement>('[data-ui-error-summary] a[href^="#"]')
546
655
  const controlId = link?.hash.slice(1)
547
- const control = controlId ? document.getElementById(controlId) : null
656
+ const control = controlId ? form.ownerDocument.getElementById(controlId) : null
548
657
 
549
- if (!link || !(control instanceof HTMLElement) || !form.contains(control)) return
658
+ if (!link || !isFormHtmlElement(control) ||
659
+ !(isNativeFormControl(control) ? control.form === form : form.contains(control))) return
550
660
 
551
661
  event.preventDefault()
552
662
 
@@ -555,19 +665,6 @@ const initForms = (scope: ParentNode): void => {
555
665
  control.scrollIntoView({ behavior: 'smooth', block: 'center' })
556
666
  })
557
667
 
558
- form.addEventListener('focusout', event => {
559
- if (!isNativeFormControl(event.target) || event.target.form !== form) return
560
-
561
- const valid = validateControl(event.target, form)
562
-
563
- form.dispatchEvent(new CustomEvent(valid ? 'ui:valid' : 'ui:invalid', {
564
- bubbles: true,
565
- detail: valid ?
566
- { control: event.target, form } :
567
- { control: event.target, controls: [event.target], form }
568
- }))
569
- })
570
-
571
668
  form.addEventListener('submit', event => {
572
669
  if (form.dataset.status === 'submitting') {
573
670
  event.preventDefault()
@@ -641,7 +738,7 @@ const initPasswordFields = (scope: ParentNode): void => {
641
738
 
642
739
  toggle.setAttribute('aria-pressed', String(visible))
643
740
 
644
- if (visibleLabel) visibleLabel.textContent = visible ? 'Hide' : 'Show'
741
+ if (visibleLabel) visibleLabel.textContent = visible ? hideLabel : showLabel
645
742
  }
646
743
 
647
744
  toggle.addEventListener('click', () => {
@@ -969,7 +1066,7 @@ const initTabs = (scope: ParentNode): void => {
969
1066
 
970
1067
  if (initiallySelected) activate(initiallySelected, false)
971
1068
 
972
- for (const [index, tab] of tabs.entries()) {
1069
+ for (const tab of tabs) {
973
1070
  tab.addEventListener('click', () => activate(tab))
974
1071
 
975
1072
  tab.addEventListener('keydown', event => {
@@ -981,12 +1078,14 @@ const initTabs = (scope: ParentNode): void => {
981
1078
 
982
1079
  event.preventDefault()
983
1080
 
984
- const nextTab = tabs[
1081
+ const enabledTabs = tabs.filter(candidate => !candidate.matches(':disabled, [aria-disabled="true"]'))
1082
+
1083
+ const nextTab = enabledTabs[
985
1084
  getLoopedIndex(
986
1085
  event.key,
987
- index,
988
- tabs.length,
989
- orientation === 'vertical' ? ['ArrowDown'] : ['ArrowRight']
1086
+ Math.max(0, enabledTabs.indexOf(tab)),
1087
+ enabledTabs.length,
1088
+ orientation === 'vertical' ? ['ArrowDown'] : [getLumenDirectionalKey(tab, 'ArrowRight')]
990
1089
  )
991
1090
  ]
992
1091
 
@@ -1003,7 +1102,6 @@ const initTabs = (scope: ParentNode): void => {
1003
1102
  const initRovingGroups = (scope: ParentNode): void => {
1004
1103
  const groupSelector = [
1005
1104
  '[data-ui-menubar]',
1006
- '[data-ui-navigation-menu]',
1007
1105
  '[data-ui-radio-group]',
1008
1106
  '[data-ui-toggle-group]',
1009
1107
  '[data-ui-toolbar]'
@@ -1035,7 +1133,7 @@ const initRovingGroups = (scope: ParentNode): void => {
1035
1133
  nextItem.tabIndex = -1
1036
1134
  }
1037
1135
 
1038
- const nextItem = items[getLoopedIndex(event.key, index, items.length, ['ArrowRight', 'ArrowDown'])]
1136
+ const nextItem = items[getLoopedIndex(getLumenDirectionalKey(root, event.key), index, items.length, ['ArrowRight', 'ArrowDown'])]
1039
1137
 
1040
1138
  if (nextItem) {
1041
1139
  nextItem.tabIndex = 0
@@ -1201,6 +1299,12 @@ const initThemeToggles = (scope: ParentNode): void => {
1201
1299
 
1202
1300
  control.dataset.uiBound = 'true'
1203
1301
 
1302
+ if (control.hasAttribute('data-ui-controlled')) {
1303
+ sync(root.dataset.theme ?? control.dataset.lightTheme ?? 'light')
1304
+
1305
+ continue
1306
+ }
1307
+
1204
1308
  const storageKey = control.dataset.storageKey ?? 'lumen-theme'
1205
1309
  let storedTheme: string | null = null
1206
1310
 
@@ -1333,41 +1437,10 @@ const initLanguageToggles = (scope: ParentNode): void => {
1333
1437
  }
1334
1438
  }
1335
1439
 
1336
- const getVisibleItems = (items: HTMLElement[]): HTMLElement[] => items.filter(item => !item.hidden &&
1337
- item.offsetParent !== null &&
1338
- !item.hasAttribute('disabled') &&
1339
- item.getAttribute('aria-disabled') !== 'true')
1340
-
1341
- const closeCombobox = (root: HTMLElement): void => {
1342
- const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
1343
- const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
1344
-
1345
- if (!input || !listbox) return
1346
-
1347
- input.setAttribute('aria-expanded', 'false')
1348
-
1349
- listbox.hidden = true
1350
-
1351
- listbox.dataset.state = 'closed'
1352
- }
1353
-
1354
- const openCombobox = (root: HTMLElement): void => {
1355
- const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
1356
- const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
1357
-
1358
- if (!input || !listbox) return
1359
-
1360
- input.setAttribute('aria-expanded', 'true')
1361
-
1362
- listbox.hidden = false
1363
-
1364
- listbox.dataset.state = 'open'
1365
- }
1366
-
1367
1440
  let codeCopyLiveRegion: HTMLElement | undefined
1368
1441
 
1369
1442
  const getCodeCopyLiveRegion = (): HTMLElement => {
1370
- if (codeCopyLiveRegion) return codeCopyLiveRegion
1443
+ if (codeCopyLiveRegion?.isConnected) return codeCopyLiveRegion
1371
1444
 
1372
1445
  codeCopyLiveRegion = document.createElement('div')
1373
1446
 
@@ -1392,39 +1465,19 @@ const getCodeCopyLiveRegion = (): HTMLElement => {
1392
1465
  return codeCopyLiveRegion
1393
1466
  }
1394
1467
 
1395
- const initCodeCopy = (scope: ParentNode): void => {
1396
- for (const button of scope.querySelectorAll<HTMLButtonElement>('[data-ui-code-copy]')) {
1397
- if (button.dataset.uiCodeCopyBound === 'true') continue
1468
+ const initCodeRegions = (scope: ParentNode): void => {
1469
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-code]')) {
1470
+ if (root.classList.contains('ui-code--wrap')) continue
1398
1471
 
1399
- button.dataset.uiCodeCopyBound = 'true'
1472
+ for (const pre of root.querySelectorAll<HTMLPreElement>('pre')) {
1473
+ if (!pre.hasAttribute('tabindex')) pre.tabIndex = 0
1400
1474
 
1401
- button.addEventListener('click', async () => {
1402
- const root = button.closest<HTMLElement>('[data-ui-code]')
1403
- const code = root?.querySelector<HTMLElement>('pre code, code')
1404
- const text = code?.innerText ?? code?.textContent
1475
+ if (!pre.hasAttribute('role')) pre.setAttribute('role', 'region')
1405
1476
 
1406
- if (!text || !navigator.clipboard) return
1407
-
1408
- try {
1409
- await navigator.clipboard.writeText(text)
1410
- } catch {
1411
- return
1477
+ if (!pre.hasAttribute('aria-label') && !pre.hasAttribute('aria-labelledby')) {
1478
+ pre.setAttribute('aria-label', root.dataset.codeLabel ?? 'Code example')
1412
1479
  }
1413
-
1414
- button.classList.add('ui-code__copy--copied')
1415
-
1416
- button.setAttribute('aria-label', 'Code copied!')
1417
-
1418
- getCodeCopyLiveRegion().textContent = 'Code copied to clipboard'
1419
-
1420
- window.setTimeout(() => {
1421
- button.classList.remove('ui-code__copy--copied')
1422
-
1423
- button.setAttribute('aria-label', 'Copy code to clipboard')
1424
-
1425
- getCodeCopyLiveRegion().textContent = ''
1426
- }, 2000)
1427
- })
1480
+ }
1428
1481
  }
1429
1482
  }
1430
1483
 
@@ -1433,6 +1486,12 @@ const getCopyButtonValue = (button: HTMLElement): string | undefined => {
1433
1486
  return button.dataset.copyValue
1434
1487
  }
1435
1488
 
1489
+ if (button.hasAttribute('data-ui-code-copy')) {
1490
+ const code = button.closest('[data-ui-code]')?.querySelector<HTMLElement>('pre code, code')
1491
+
1492
+ return code?.innerText ?? code?.textContent ?? undefined
1493
+ }
1494
+
1436
1495
  const selector = button.dataset.copyTarget
1437
1496
 
1438
1497
  if (!selector) return undefined
@@ -1448,17 +1507,20 @@ const getCopyButtonValue = (button: HTMLElement): string | undefined => {
1448
1507
 
1449
1508
  const initCopyButtons = (scope: ParentNode): void => {
1450
1509
  for (const button of scope.querySelectorAll<HTMLButtonElement>(
1451
- '[data-ui-copy-button]'
1510
+ '[data-ui-copy-button], [data-ui-code-copy]'
1452
1511
  )) {
1453
1512
  if (button.dataset.uiCopyButtonBound === 'true') continue
1454
1513
 
1455
1514
  button.dataset.uiCopyButtonBound = 'true'
1456
1515
 
1457
1516
  let resetTimer = 0
1517
+ let operation = 0
1458
1518
 
1459
1519
  const setCopyButtonState = (state: 'copied' | 'error' | 'idle'): void => {
1460
1520
  button.dataset.state = state
1461
1521
 
1522
+ button.classList.toggle('ui-code__copy--copied', state === 'copied' && button.hasAttribute('data-ui-code-copy'))
1523
+
1462
1524
  for (const part of button.querySelectorAll<HTMLElement>('[data-slot^="copy-"]')) {
1463
1525
  const isActive = part.dataset.slot === `copy-${state}`
1464
1526
 
@@ -1471,6 +1533,10 @@ const initCopyButtons = (scope: ParentNode): void => {
1471
1533
  setCopyButtonState('idle')
1472
1534
 
1473
1535
  button.addEventListener('click', async () => {
1536
+ const currentOperation = ++operation
1537
+
1538
+ window.clearTimeout(resetTimer)
1539
+
1474
1540
  const label = button.dataset.copyLabel ?? 'Copy to clipboard'
1475
1541
 
1476
1542
  const copiedLabel = button.dataset.copyCopiedLabel ??
@@ -1488,10 +1554,14 @@ const initCopyButtons = (scope: ParentNode): void => {
1488
1554
 
1489
1555
  await navigator.clipboard.writeText(value)
1490
1556
 
1557
+ if (operation !== currentOperation || !button.isConnected) return
1558
+
1491
1559
  setCopyButtonState('copied')
1492
1560
 
1493
1561
  button.setAttribute('aria-label', copiedLabel)
1494
1562
 
1563
+ if (button.hasAttribute('data-ui-code-copy')) button.title = copiedLabel
1564
+
1495
1565
  button.dispatchEvent(new CustomEvent('ui:copy-success', {
1496
1566
  bubbles: true,
1497
1567
  detail: { value }
@@ -1505,10 +1575,14 @@ const initCopyButtons = (scope: ParentNode): void => {
1505
1575
  }))
1506
1576
  }
1507
1577
  } catch (error) {
1578
+ if (operation !== currentOperation || !button.isConnected) return
1579
+
1508
1580
  setCopyButtonState('error')
1509
1581
 
1510
1582
  button.setAttribute('aria-label', errorLabel)
1511
1583
 
1584
+ if (button.hasAttribute('data-ui-code-copy')) button.title = errorLabel
1585
+
1512
1586
  button.dispatchEvent(new CustomEvent('ui:copy-error', {
1513
1587
  bubbles: true,
1514
1588
  detail: { error }
@@ -1536,6 +1610,8 @@ const initCopyButtons = (scope: ParentNode): void => {
1536
1610
 
1537
1611
  button.setAttribute('aria-label', label)
1538
1612
 
1613
+ if (button.hasAttribute('data-ui-code-copy')) button.title = label
1614
+
1539
1615
  getCodeCopyLiveRegion().textContent = ''
1540
1616
  }, resetAfter)
1541
1617
  })
@@ -1596,22 +1672,6 @@ const filterItems = (input: HTMLInputElement, items: HTMLElement[]): void => {
1596
1672
  }
1597
1673
  }
1598
1674
 
1599
- const selectComboboxOption = (root: HTMLElement, option: HTMLElement): void => {
1600
- const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
1601
-
1602
- if (!input) return
1603
-
1604
- input.value = option.dataset.value ?? option.textContent?.trim() ?? ''
1605
-
1606
- input.dispatchEvent(new Event('input', { bubbles: true }))
1607
-
1608
- input.dispatchEvent(new Event('change', { bubbles: true }))
1609
-
1610
- closeCombobox(root)
1611
-
1612
- input.focus({ preventScroll: true })
1613
- }
1614
-
1615
1675
  const closeSelect = (root: HTMLElement): void => {
1616
1676
  const trigger = root.querySelector<HTMLElement>('[data-ui-select-trigger]')
1617
1677
  const listbox = root.querySelector<HTMLElement>('[data-ui-select-list]')
@@ -1741,21 +1801,6 @@ const focusSelectOption = (
1741
1801
  nextItem?.focus()
1742
1802
  }
1743
1803
 
1744
- const focusLoopedOption = (
1745
- items: HTMLElement[],
1746
- currentItem: HTMLElement,
1747
- key: string
1748
- ): void => {
1749
- const visibleItems = getVisibleItems(items)
1750
-
1751
- if (!visibleItems.length) return
1752
-
1753
- const currentIndex = visibleItems.indexOf(currentItem)
1754
- const nextItem = visibleItems[getLoopedIndex(key, Math.max(0, currentIndex), visibleItems.length, ['ArrowDown'])]
1755
-
1756
- nextItem?.focus()
1757
- }
1758
-
1759
1804
  const initCommand = (scope: ParentNode): void => {
1760
1805
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-command]')) {
1761
1806
  if (root.dataset.uiBound === 'true') continue
@@ -1774,88 +1819,7 @@ const initCommand = (scope: ParentNode): void => {
1774
1819
  }
1775
1820
  }
1776
1821
 
1777
- const initComboboxes = (scope: ParentNode): void => {
1778
- for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
1779
- if (root.dataset.uiBound === 'true') continue
1780
-
1781
- const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')
1782
- const listbox = root.querySelector<HTMLElement>('[role="listbox"]')
1783
-
1784
- if (!input || !listbox) continue
1785
-
1786
- root.dataset.uiBound = 'true'
1787
-
1788
- const items = [...listbox.querySelectorAll<HTMLElement>('[role="option"]')]
1789
-
1790
- closeCombobox(root)
1791
-
1792
- input.addEventListener('focus', () => openCombobox(root))
1793
-
1794
- input.addEventListener('input', () => {
1795
- filterItems(input, items)
1796
-
1797
- openCombobox(root)
1798
- })
1799
-
1800
- input.addEventListener('keydown', event => {
1801
- if (event.key === 'Escape') {
1802
- closeCombobox(root)
1803
-
1804
- return
1805
- }
1806
-
1807
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return
1808
-
1809
- const visibleItems = getVisibleItems(items)
1810
-
1811
- if (!visibleItems.length) return
1812
-
1813
- event.preventDefault()
1814
-
1815
- openCombobox(root)
1816
-
1817
- if (event.key === 'Enter') {
1818
- selectComboboxOption(root, visibleItems[0])
1819
-
1820
- return
1821
- }
1822
-
1823
- const targetIndex = event.key === 'ArrowUp' ? visibleItems.length - 1 : 0
1824
-
1825
- visibleItems[targetIndex]?.focus()
1826
- })
1827
-
1828
- for (const item of items) {
1829
- item.tabIndex = -1
1830
-
1831
- item.addEventListener('click', () => selectComboboxOption(root, item))
1832
-
1833
- item.addEventListener('keydown', event => {
1834
- if (event.key === 'Escape') {
1835
- closeCombobox(root)
1836
-
1837
- input.focus({ preventScroll: true })
1838
-
1839
- return
1840
- }
1841
-
1842
- if (event.key === 'Enter' || event.key === ' ') {
1843
- event.preventDefault()
1844
-
1845
- selectComboboxOption(root, item)
1846
-
1847
- return
1848
- }
1849
-
1850
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') return
1851
-
1852
- event.preventDefault()
1853
-
1854
- focusLoopedOption(items, item, event.key)
1855
- })
1856
- }
1857
- }
1858
- }
1822
+ document.addEventListener('astro:before-swap', clearComboboxes)
1859
1823
 
1860
1824
  const initSelects = (scope: ParentNode): void => {
1861
1825
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-select]')) {
@@ -1904,6 +1868,12 @@ const initSelects = (scope: ParentNode): void => {
1904
1868
  }
1905
1869
  }
1906
1870
 
1871
+ for (const attribute of ['aria-label', 'aria-labelledby']) {
1872
+ const value = trigger.getAttribute(attribute)
1873
+
1874
+ if (value) listbox.setAttribute(attribute, value)
1875
+ }
1876
+
1907
1877
  for (const label of select.labels ?? []) {
1908
1878
  label.addEventListener('click', event => {
1909
1879
  event.preventDefault()
@@ -1930,7 +1900,9 @@ const initSelects = (scope: ParentNode): void => {
1930
1900
  }
1931
1901
 
1932
1902
  const handleTypeahead = (event: KeyboardEvent, currentItem?: HTMLElement): boolean => {
1933
- if (!isPrintableKey(event)) return false
1903
+ if (!isPrintableKey(event) || (
1904
+ event.key === ' ' && (!typeahead || trigger.getAttribute('aria-expanded') !== 'true')
1905
+ )) return false
1934
1906
 
1935
1907
  event.preventDefault()
1936
1908
 
@@ -1953,9 +1925,23 @@ const initSelects = (scope: ParentNode): void => {
1953
1925
 
1954
1926
  closeSelect(root)
1955
1927
 
1928
+ root.addEventListener('focusout', event => {
1929
+ if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return
1930
+
1931
+ typeahead = ''
1932
+
1933
+ window.clearTimeout(typeaheadTimer)
1934
+
1935
+ closeSelect(root)
1936
+ })
1937
+
1956
1938
  trigger.addEventListener('click', () => {
1957
1939
  const isOpen = trigger.getAttribute('aria-expanded') === 'true'
1958
1940
 
1941
+ typeahead = ''
1942
+
1943
+ window.clearTimeout(typeaheadTimer)
1944
+
1959
1945
  if (isOpen) {
1960
1946
  closeSelect(root)
1961
1947
  } else {
@@ -1964,9 +1950,13 @@ const initSelects = (scope: ParentNode): void => {
1964
1950
  })
1965
1951
 
1966
1952
  trigger.addEventListener('keydown', event => {
1953
+ if (event.defaultPrevented || event.isComposing) return
1954
+
1967
1955
  if (handleTypeahead(event)) return
1968
1956
 
1969
- if (event.key === 'Escape') {
1957
+ if (event.key === 'Escape' && !listbox.hidden) {
1958
+ event.preventDefault()
1959
+
1970
1960
  closeSelect(root)
1971
1961
 
1972
1962
  return
@@ -1978,6 +1968,10 @@ const initSelects = (scope: ParentNode): void => {
1978
1968
 
1979
1969
  event.preventDefault()
1980
1970
 
1971
+ typeahead = ''
1972
+
1973
+ window.clearTimeout(typeaheadTimer)
1974
+
1981
1975
  openSelect(root)
1982
1976
 
1983
1977
  if (event.key === 'Enter' || event.key === ' ') {
@@ -2003,9 +1997,13 @@ const initSelects = (scope: ParentNode): void => {
2003
1997
  item.addEventListener('click', () => selectSelectOption(root, item))
2004
1998
 
2005
1999
  item.addEventListener('keydown', event => {
2000
+ if (event.defaultPrevented || event.isComposing) return
2001
+
2006
2002
  if (handleTypeahead(event, item)) return
2007
2003
 
2008
- if (event.key === 'Escape') {
2004
+ if (event.key === 'Escape' && !listbox.hidden) {
2005
+ event.preventDefault()
2006
+
2009
2007
  closeSelect(root)
2010
2008
 
2011
2009
  trigger.focus({ preventScroll: true })
@@ -2025,6 +2023,10 @@ const initSelects = (scope: ParentNode): void => {
2025
2023
 
2026
2024
  event.preventDefault()
2027
2025
 
2026
+ typeahead = ''
2027
+
2028
+ window.clearTimeout(typeaheadTimer)
2029
+
2028
2030
  focusSelectOption(root, event.key, item)
2029
2031
  })
2030
2032
  }
@@ -2173,19 +2175,26 @@ const initResizableGroups = (scope: ParentNode): void => {
2173
2175
  if (handle.dataset.active !== 'true') return
2174
2176
 
2175
2177
  const delta = ((event[axis] - startPosition) / containerSize) * 100
2178
+ const multiplier = direction === 'horizontal' && getLumenDirectionalKey(handle, 'ArrowRight') === 'ArrowLeft' ? -1 : 1
2176
2179
 
2177
- resizePair(index, startSize + delta)
2180
+ resizePair(index, startSize + delta * multiplier)
2178
2181
  })
2179
2182
 
2180
- handle.addEventListener('pointerup', event => {
2183
+ const finishResize = (event: PointerEvent): void => {
2181
2184
  if (handle.dataset.active !== 'true') return
2182
2185
 
2183
2186
  delete handle.dataset.active
2184
2187
 
2185
2188
  delete root.dataset.resizing
2186
2189
 
2187
- handle.releasePointerCapture(event.pointerId)
2188
- })
2190
+ if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId)
2191
+ }
2192
+
2193
+ handle.addEventListener('pointerup', finishResize)
2194
+
2195
+ handle.addEventListener('pointercancel', finishResize)
2196
+
2197
+ handle.addEventListener('lostpointercapture', finishResize)
2189
2198
 
2190
2199
  handle.addEventListener('dblclick', () => {
2191
2200
  if (root.dataset.uiResizableReset !== 'true') return
@@ -2218,7 +2227,7 @@ const initResizableGroups = (scope: ParentNode): void => {
2218
2227
  return
2219
2228
  }
2220
2229
 
2221
- const delta = keyDeltas[event.key]
2230
+ const delta = keyDeltas[getLumenDirectionalKey(handle, event.key)]
2222
2231
 
2223
2232
  if (delta === undefined) return
2224
2233
 
@@ -2375,132 +2384,6 @@ const initInputOtpFields = (scope: ParentNode): void => {
2375
2384
  }
2376
2385
 
2377
2386
  /* cspell:ignore Datatable datatable */
2378
- /* eslint-disable @stylistic/padding-line-between-statements, no-nested-ternary */
2379
- const getDataTableSortValue = (
2380
- cell: HTMLTableCellElement | undefined
2381
- ): string => cell?.dataset.sortValue ?? cell?.textContent?.trim() ?? ''
2382
-
2383
- const compareDataTableValues = (value: string, other: string, sortType: string | undefined): number => {
2384
- const numericValue = Number(value.replace(/,/g, ''))
2385
- const numericOther = Number(other.replace(/,/g, ''))
2386
- const useNumeric = sortType === 'number' || (
2387
- sortType !== 'string' &&
2388
- value.trim() !== '' &&
2389
- other.trim() !== '' &&
2390
- Number.isFinite(numericValue) &&
2391
- Number.isFinite(numericOther)
2392
- )
2393
-
2394
- if (useNumeric) {
2395
- return numericValue - numericOther
2396
- }
2397
-
2398
- return value.localeCompare(other, undefined, { numeric: true, sensitivity: 'base' })
2399
- }
2400
-
2401
- const sortDataTableRows = (
2402
- table: HTMLTableElement,
2403
- header: HTMLTableCellElement,
2404
- columnIndex: number,
2405
- direction: 'ascending' | 'descending' | 'none'
2406
- ): void => {
2407
- const body = table.tBodies[0]
2408
-
2409
- if (!body) return
2410
-
2411
- const rows = [...body.rows]
2412
- const sortType = header.dataset.uiDatatableSortType
2413
-
2414
- rows.sort((row, other) => {
2415
- if (direction === 'none') {
2416
- return Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
2417
- }
2418
-
2419
- const result = compareDataTableValues(
2420
- getDataTableSortValue(row.cells[columnIndex]), getDataTableSortValue(other.cells[columnIndex]), sortType
2421
- )
2422
-
2423
- return (direction === 'ascending' ? result : -result) ||
2424
- Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
2425
- })
2426
-
2427
- rows.forEach(row => body.append(row))
2428
- }
2429
-
2430
- const updateDataTableSort = (
2431
- root: HTMLElement,
2432
- table: HTMLTableElement,
2433
- header: HTMLTableCellElement,
2434
- direction: 'ascending' | 'descending' | 'none'
2435
- ): void => {
2436
- for (const item of table.querySelectorAll<HTMLTableCellElement>('thead th[aria-sort]')) {
2437
- item.setAttribute('aria-sort', item === header ? direction : 'none')
2438
- item.dataset.sort = item === header ? direction : 'none'
2439
- }
2440
-
2441
- root.dataset.uiDatatableSortDirection = direction
2442
-
2443
- if (direction === 'none') {
2444
- delete root.dataset.uiDatatableSortColumn
2445
- } else {
2446
- root.dataset.uiDatatableSortColumn = header.dataset.uiDatatableColumn ?? ''
2447
- }
2448
- }
2449
-
2450
- const initDataTableSorting = (root: HTMLElement, table: HTMLTableElement): void => {
2451
- const headers = [...table.querySelectorAll<HTMLTableCellElement>('thead th')].filter(
2452
- header => header.dataset.uiDatatableSortable === 'true' ||
2453
- header.dataset.sortable === 'true' ||
2454
- header.hasAttribute('data-sortable')
2455
- )
2456
-
2457
- for (const header of headers) {
2458
- if (header.dataset.uiDatatableSortBound === 'true') continue
2459
-
2460
- const row = header.parentElement
2461
- const columnIndex = row ? [...row.children].indexOf(header) : -1
2462
-
2463
- if (columnIndex < 0) continue
2464
-
2465
- header.dataset.uiDatatableSortBound = 'true'
2466
- header.dataset.uiDatatableColumn = String(columnIndex)
2467
- header.setAttribute('aria-sort', 'none')
2468
-
2469
- const button = document.createElement('button')
2470
-
2471
- button.type = 'button'
2472
- button.className = 'ui-data-table__sort'
2473
- button.dataset.uiDatatableSort = ''
2474
-
2475
- while (header.firstChild) {
2476
- button.append(header.firstChild)
2477
- }
2478
-
2479
- header.append(button)
2480
-
2481
- button.addEventListener('click', () => {
2482
- const currentColumn = root.dataset.uiDatatableSortColumn
2483
- const currentDirection = header.getAttribute('aria-sort')
2484
- const nextDirection = currentColumn !== String(columnIndex) || currentDirection === 'none' ?
2485
- 'ascending' :
2486
- currentDirection === 'ascending' ?
2487
- 'descending' :
2488
- 'none'
2489
-
2490
- updateDataTableSort(root, table, header, nextDirection)
2491
- sortDataTableRows(table, header, columnIndex, nextDirection)
2492
- })
2493
- }
2494
-
2495
- const body = table.tBodies[0]
2496
-
2497
- if (!body) return
2498
-
2499
- ;[...body.rows].forEach((row, index) => {
2500
- row.dataset.uiDatatableIndex = row.dataset.uiDatatableIndex ?? String(index)
2501
- })
2502
- }
2503
-
2504
2387
  const getDataTableRows = (table: HTMLTableElement): HTMLTableRowElement[] => [
2505
2388
  ...(table.tBodies[0]?.rows ?? [])
2506
2389
  ]
@@ -2527,6 +2410,7 @@ const syncDataTableSelection = (
2527
2410
 
2528
2411
  if (selected) {
2529
2412
  row.dataset.state = 'selected'
2413
+
2530
2414
  selectedValues.push(getDataTableRowValue(row, index))
2531
2415
  } else {
2532
2416
  delete row.dataset.state
@@ -2534,6 +2418,7 @@ const syncDataTableSelection = (
2534
2418
  })
2535
2419
 
2536
2420
  selectAll.checked = rows.length > 0 && selectedValues.length === rows.length
2421
+
2537
2422
  selectAll.indeterminate = selectedValues.length > 0 && selectedValues.length < rows.length
2538
2423
 
2539
2424
  const name = root.dataset.uiDatatableName
@@ -2542,8 +2427,11 @@ const syncDataTableSelection = (
2542
2427
  if (name) {
2543
2428
  if (!inputs) {
2544
2429
  inputs = document.createElement('div')
2430
+
2545
2431
  inputs.hidden = true
2432
+
2546
2433
  inputs.dataset.uiDatatableInputs = ''
2434
+
2547
2435
  root.append(inputs)
2548
2436
  }
2549
2437
 
@@ -2551,7 +2439,9 @@ const syncDataTableSelection = (
2551
2439
  const input = document.createElement('input')
2552
2440
 
2553
2441
  input.type = 'hidden'
2442
+
2554
2443
  input.name = name
2444
+
2555
2445
  input.value = value
2556
2446
 
2557
2447
  return input
@@ -2581,16 +2471,24 @@ const initDataTableSelection = (root: HTMLElement, table: HTMLTableElement): voi
2581
2471
  const selectAll = document.createElement('input')
2582
2472
 
2583
2473
  headerCell.className = 'ui-data-table__selection'
2474
+
2584
2475
  headerCell.scope = 'col'
2476
+
2585
2477
  selectAll.type = 'checkbox'
2478
+
2586
2479
  selectAll.className = 'ui-data-table__checkbox'
2480
+
2587
2481
  selectAll.dataset.uiDatatableSelectAll = ''
2482
+
2588
2483
  selectAll.setAttribute('aria-label', 'Select all rows')
2484
+
2589
2485
  headerCell.append(selectAll)
2486
+
2590
2487
  headRow.prepend(headerCell)
2591
2488
 
2592
2489
  getDataTableRows(table).forEach((row, index) => {
2593
2490
  row.dataset.uiDatatableRow = ''
2491
+
2594
2492
  row.dataset.value = getDataTableRowValue(row, index)
2595
2493
 
2596
2494
  const cell = document.createElement('td')
@@ -2598,15 +2496,23 @@ const initDataTableSelection = (root: HTMLElement, table: HTMLTableElement): voi
2598
2496
  const selected = row.dataset.state === 'selected' || row.getAttribute('aria-selected') === 'true'
2599
2497
 
2600
2498
  cell.className = 'ui-data-table__selection'
2499
+
2601
2500
  checkbox.type = 'checkbox'
2501
+
2602
2502
  checkbox.className = 'ui-data-table__checkbox'
2503
+
2603
2504
  checkbox.dataset.uiDatatableRowSelect = ''
2505
+
2604
2506
  checkbox.checked = selected
2507
+
2605
2508
  checkbox.defaultChecked = selected
2509
+
2606
2510
  checkbox.setAttribute('aria-label', `Select row ${index + 1}`)
2511
+
2607
2512
  checkbox.addEventListener('change', () => syncDataTableSelection(root, table, selectAll, true))
2608
2513
 
2609
2514
  cell.append(checkbox)
2515
+
2610
2516
  row.prepend(cell)
2611
2517
  })
2612
2518
 
@@ -2636,71 +2542,63 @@ const initDataTables = (scope: ParentNode): void => {
2636
2542
  root.dataset.uiBound = 'true'
2637
2543
 
2638
2544
  initDataTableSelection(root, table)
2545
+
2639
2546
  initDataTableSorting(root, table)
2640
2547
  }
2641
2548
  }
2642
- /* eslint-enable @stylistic/padding-line-between-statements, no-nested-ternary */
2643
-
2644
- const calendarDatePattern = /^\d{4}-\d{2}-\d{2}$/
2645
- const calendarMonthPattern = /^\d{4}-\d{2}$/
2646
-
2647
- const parseCalendarDate = (value: string | null | undefined): Date | null => {
2648
- if (!value || !calendarDatePattern.test(value)) return null
2649
-
2650
- const [year, month, day] = value.split('-').map(Number)
2651
- const date = new Date(Date.UTC(year, month - 1, day))
2652
-
2653
- return date.getUTCFullYear() === year &&
2654
- date.getUTCMonth() === month - 1 &&
2655
- date.getUTCDate() === day ?
2656
- date :
2657
- null
2658
- }
2659
2549
 
2660
- const parseCalendarMonth = (value: string | null | undefined): Date | null => {
2661
- if (!value || !calendarMonthPattern.test(value)) return null
2550
+ const createCalendarDate = (year: number, month: number, day: number): Date => {
2551
+ const date = new Date(0)
2662
2552
 
2663
- const [year, month] = value.split('-').map(Number)
2664
- const date = new Date(Date.UTC(year, month - 1, 1))
2553
+ date.setUTCFullYear(year, month, day)
2665
2554
 
2666
- return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 ? date : null
2555
+ return date
2667
2556
  }
2668
2557
 
2669
- const formatCalendarDate = (date: Date): string => date.toISOString().slice(0, 10)
2670
- const formatCalendarMonth = (date: Date): string => date.toISOString().slice(0, 7)
2558
+ const parseCalendarMonth = (value: string | null | undefined): Date | null => value ? parseCalendarDate(`${value}-01`) : null
2559
+ const formatCalendarDate = (date: Date): string => date.toISOString().split('T')[0] ?? ''
2560
+ const formatCalendarMonth = (date: Date): string => formatCalendarDate(date).slice(0, -3)
2671
2561
 
2672
- const addCalendarDays = (date: Date, days: number): Date => new Date(
2673
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)
2562
+ const addCalendarDays = (date: Date, days: number): Date => createCalendarDate(
2563
+ date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days
2674
2564
  )
2675
2565
 
2676
- const getCalendarDaysInMonth = (date: Date): number => new Date(
2677
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)
2566
+ const getCalendarDaysInMonth = (date: Date): number => createCalendarDate(
2567
+ date.getUTCFullYear(), date.getUTCMonth() + 1, 0
2678
2568
  ).getUTCDate()
2679
2569
 
2680
2570
  const addCalendarMonths = (date: Date, months: number): Date => {
2681
- const year = date.getUTCFullYear()
2682
- const month = date.getUTCMonth() + months
2683
- const day = date.getUTCDate()
2684
- const targetMonth = new Date(Date.UTC(year, month, 1))
2685
- const daysInTargetMonth = getCalendarDaysInMonth(targetMonth)
2686
-
2687
- return new Date(Date.UTC(
2688
- targetMonth.getUTCFullYear(), targetMonth.getUTCMonth(), Math.min(day, daysInTargetMonth)
2689
- ))
2571
+ const target = createCalendarDate(date.getUTCFullYear(), date.getUTCMonth() + months, 1)
2572
+
2573
+ return createCalendarDate(
2574
+ target.getUTCFullYear(), target.getUTCMonth(), Math.min(date.getUTCDate(), getCalendarDaysInMonth(target))
2575
+ )
2690
2576
  }
2691
2577
 
2692
- const compareCalendarDates = (date: Date, other: Date | null): number => {
2693
- if (!other) return 0
2578
+ const compareCalendarDates = (date: Date, other: Date | null): number => (
2579
+ other ? Math.sign(date.getTime() - other.getTime()) : 0
2580
+ )
2581
+
2582
+ const getCalendarLocale = (root: HTMLElement): string => resolveLumenDateLocale(root.getAttribute('locale') || root.dataset.locale || root.closest('[lang]')?.getAttribute('lang') || undefined)
2583
+ const getCalendarMin = (root: HTMLElement): Date | null => parseCalendarDate(root.getAttribute('min') ?? root.dataset.uiCalendarMin ?? '0001-01-01')
2584
+ const getCalendarMax = (root: HTMLElement): Date | null => parseCalendarDate(root.getAttribute('max') ?? root.dataset.uiCalendarMax ?? '9999-12-31')
2585
+ const isCalendarDisabled = (root: HTMLElement): boolean => root.hasAttribute('disabled') || root.dataset.disabled === 'true' || !isLumenDateBoundsValid(root.getAttribute('min') ?? root.dataset.uiCalendarMin, root.getAttribute('max') ?? root.dataset.uiCalendarMax)
2586
+ const isCalendarReadOnly = (root: HTMLElement): boolean => root.hasAttribute('readonly') || root.dataset.readonly === 'true'
2587
+
2588
+ const getCalendarToday = (): Date => {
2589
+ const today = new Date()
2694
2590
 
2695
- return formatCalendarDate(date).localeCompare(formatCalendarDate(other))
2591
+ return createCalendarDate(today.getFullYear(), today.getMonth(), today.getDate())
2696
2592
  }
2697
2593
 
2594
+ const getCalendarGridStart = (month: Date): Date => addCalendarDays(month, -((month.getUTCDay() + 6) % 7))
2595
+
2698
2596
  const isCalendarDateDisabled = (
2699
2597
  root: HTMLElement,
2700
2598
  date: Date,
2701
2599
  min: Date | null,
2702
2600
  max: Date | null
2703
- ): boolean => root.dataset.disabled === 'true' ||
2601
+ ): boolean => isCalendarDisabled(root) ||
2704
2602
  compareCalendarDates(date, min) < 0 ||
2705
2603
  compareCalendarDates(date, max) > 0
2706
2604
 
@@ -2712,16 +2610,6 @@ const clampCalendarDate = (date: Date, min: Date | null, max: Date | null): Date
2712
2610
  return date
2713
2611
  }
2714
2612
 
2715
- const getCalendarLocale = (): string => document.documentElement.lang || navigator.language || 'en'
2716
-
2717
- const getCalendarToday = (): Date => {
2718
- const today = new Date()
2719
-
2720
- return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()))
2721
- }
2722
-
2723
- const getCalendarGridStart = (month: Date): Date => addCalendarDays(month, -((month.getUTCDay() + 6) % 7))
2724
-
2725
2613
  const getCalendarFocusDate = (
2726
2614
  root: HTMLElement,
2727
2615
  month: Date,
@@ -2760,7 +2648,7 @@ const syncCalendarNavigation = (
2760
2648
  ): void => {
2761
2649
  const previous = root.querySelector<HTMLButtonElement>('[data-ui-calendar-prev]')
2762
2650
  const next = root.querySelector<HTMLButtonElement>('[data-ui-calendar-next]')
2763
- const disabled = root.dataset.disabled === 'true'
2651
+ const disabled = isCalendarDisabled(root)
2764
2652
  const previousMonthLastDay = addCalendarDays(month, -1)
2765
2653
  const nextMonthFirstDay = addCalendarMonths(month, 1)
2766
2654
 
@@ -2781,19 +2669,32 @@ const renderCalendar = (
2781
2669
  const label = root.querySelector<HTMLElement>('[data-ui-calendar-label]')
2782
2670
  const grid = root.querySelector<HTMLElement>('[data-ui-calendar-grid]')
2783
2671
 
2784
- if (!label || !grid) return
2672
+ if (!label || !grid) return
2673
+
2674
+ const locale = getCalendarLocale(root)
2675
+ const labels = resolveLumenDateLabels(locale)
2676
+ const input = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2677
+
2678
+ if (input) input.disabled = isCalendarDisabled(root)
2679
+
2680
+ root.setAttribute('aria-disabled', String(isCalendarDisabled(root)))
2681
+
2682
+ grid.setAttribute('aria-readonly', String(isCalendarReadOnly(root)))
2785
2683
 
2786
- const locale = getCalendarLocale()
2787
- const min = parseCalendarDate(root.dataset.uiCalendarMin)
2788
- const max = parseCalendarDate(root.dataset.uiCalendarMax)
2684
+ root.querySelector('[data-ui-calendar-prev]')?.setAttribute('aria-label', root.dataset.previousMonthLabel || labels.previousMonth)
2685
+
2686
+ root.querySelector('[data-ui-calendar-next]')?.setAttribute('aria-label', root.dataset.nextMonthLabel || labels.nextMonth)
2687
+
2688
+ const min = getCalendarMin(root)
2689
+ const max = getCalendarMax(root)
2789
2690
  const selectedDate = parseCalendarDate(root.dataset.uiCalendarValue)
2790
2691
  const todayIso = formatCalendarDate(getCalendarToday())
2791
2692
 
2792
2693
  const month = parseCalendarMonth(root.dataset.uiCalendarMonth) ??
2793
- (selectedDate ? new Date(Date.UTC(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1)) : null) ??
2694
+ (selectedDate ? createCalendarDate(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1) : null) ??
2794
2695
  getCalendarToday()
2795
2696
 
2796
- const visibleMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1))
2697
+ const visibleMonth = createCalendarDate(month.getUTCFullYear(), month.getUTCMonth(), 1)
2797
2698
  const focusDate = getCalendarFocusDate(root, visibleMonth, min, max, requestedFocusDate)
2798
2699
  const focusIso = formatCalendarDate(focusDate)
2799
2700
  const selectedIso = selectedDate ? formatCalendarDate(selectedDate) : ''
@@ -2837,7 +2738,7 @@ const renderCalendar = (
2837
2738
 
2838
2739
  cell.role = 'columnheader'
2839
2740
 
2840
- cell.textContent = weekdayLabel.format(new Date(Date.UTC(2026, 0, 5 + index)))
2741
+ cell.textContent = weekdayLabel.format(createCalendarDate(2026, 0, 5 + index))
2841
2742
 
2842
2743
  headerRow.append(cell)
2843
2744
  }
@@ -2900,7 +2801,7 @@ const renderCalendar = (
2900
2801
  grid.replaceChildren(header, body)
2901
2802
  }
2902
2803
 
2903
- if (shouldFocus) {
2804
+ if (shouldFocus && !isCalendarDisabled(root)) {
2904
2805
  root.querySelector<HTMLElement>(`[data-ui-calendar-day][data-date="${focusIso}"]`)?.focus({ preventScroll: true })
2905
2806
  }
2906
2807
  }
@@ -2908,11 +2809,11 @@ const renderCalendar = (
2908
2809
  const selectCalendarDate = (root: HTMLElement, date: Date): void => {
2909
2810
  const input = root.querySelector<HTMLInputElement>('[data-ui-calendar-input]')
2910
2811
 
2911
- if (!input || root.dataset.disabled === 'true') return
2812
+ if (!input || isCalendarDisabled(root) || isCalendarReadOnly(root)) return
2912
2813
 
2913
- const min = parseCalendarDate(root.dataset.uiCalendarMin)
2914
- const max = parseCalendarDate(root.dataset.uiCalendarMax)
2915
- const nextDate = clampCalendarDate(date, min, max)
2814
+ const min = getCalendarMin(root)
2815
+ const max = getCalendarMax(root)
2816
+ const nextDate = date
2916
2817
 
2917
2818
  if (isCalendarDateDisabled(root, nextDate, min, max)) return
2918
2819
 
@@ -2922,16 +2823,16 @@ const selectCalendarDate = (root: HTMLElement, date: Date): void => {
2922
2823
 
2923
2824
  root.dataset.uiCalendarMonth = formatCalendarMonth(nextDate)
2924
2825
 
2826
+ renderCalendar(root, nextDate, true)
2827
+
2925
2828
  input.dispatchEvent(new Event('input', { bubbles: true }))
2926
2829
 
2927
2830
  input.dispatchEvent(new Event('change', { bubbles: true }))
2928
-
2929
- renderCalendar(root, nextDate, true)
2930
2831
  }
2931
2832
 
2932
2833
  const focusCalendarDate = (root: HTMLElement, date: Date): void => {
2933
- const min = parseCalendarDate(root.dataset.uiCalendarMin)
2934
- const max = parseCalendarDate(root.dataset.uiCalendarMax)
2834
+ const min = getCalendarMin(root)
2835
+ const max = getCalendarMax(root)
2935
2836
  const nextDate = clampCalendarDate(date, min, max)
2936
2837
 
2937
2838
  root.dataset.uiCalendarMonth = formatCalendarMonth(nextDate)
@@ -2957,7 +2858,7 @@ const moveCalendarFocus = (root: HTMLElement, currentDate: Date, key: string): v
2957
2858
  return
2958
2859
  }
2959
2860
 
2960
- const offset = keyOffsets[key]
2861
+ const offset = keyOffsets[getLumenDirectionalKey(root, key)]
2961
2862
 
2962
2863
  if (offset === undefined) return
2963
2864
 
@@ -2972,6 +2873,8 @@ const initCalendars = (scope: ParentNode): void => {
2972
2873
 
2973
2874
  if (!input) continue
2974
2875
 
2876
+ const initialValue = input.value
2877
+
2975
2878
  root.dataset.uiBound = 'true'
2976
2879
 
2977
2880
  renderCalendar(root)
@@ -2979,10 +2882,12 @@ const initCalendars = (scope: ParentNode): void => {
2979
2882
  root.querySelector<HTMLButtonElement>('[data-ui-calendar-prev]')?.addEventListener('click', () => {
2980
2883
  const month = parseCalendarMonth(root.dataset.uiCalendarMonth)
2981
2884
 
2982
- if (!month) return
2885
+ if (!month || isCalendarDisabled(root)) return
2983
2886
 
2984
2887
  const nextMonth = addCalendarMonths(month, -1)
2985
2888
 
2889
+ if (compareCalendarDates(addCalendarDays(month, -1), getCalendarMin(root)) < 0) return
2890
+
2986
2891
  root.dataset.uiCalendarMonth = formatCalendarMonth(nextMonth)
2987
2892
 
2988
2893
  renderCalendar(root, nextMonth, true)
@@ -2991,10 +2896,12 @@ const initCalendars = (scope: ParentNode): void => {
2991
2896
  root.querySelector<HTMLButtonElement>('[data-ui-calendar-next]')?.addEventListener('click', () => {
2992
2897
  const month = parseCalendarMonth(root.dataset.uiCalendarMonth)
2993
2898
 
2994
- if (!month) return
2899
+ if (!month || isCalendarDisabled(root)) return
2995
2900
 
2996
2901
  const nextMonth = addCalendarMonths(month, 1)
2997
2902
 
2903
+ if (compareCalendarDates(nextMonth, getCalendarMax(root)) > 0) return
2904
+
2998
2905
  root.dataset.uiCalendarMonth = formatCalendarMonth(nextMonth)
2999
2906
 
3000
2907
  renderCalendar(root, nextMonth, true)
@@ -3023,7 +2930,7 @@ const initCalendars = (scope: ParentNode): void => {
3023
2930
 
3024
2931
  const date = parseCalendarDate(target.dataset.date)
3025
2932
 
3026
- if (!date) return
2933
+ if (!date || isCalendarDisabled(root)) return
3027
2934
 
3028
2935
  if (event.key === 'Enter' || event.key === ' ') {
3029
2936
  event.preventDefault()
@@ -3051,9 +2958,37 @@ const initCalendars = (scope: ParentNode): void => {
3051
2958
  moveCalendarFocus(root, date, event.key)
3052
2959
  })
3053
2960
 
3054
- input.form?.addEventListener('reset', () => {
3055
- window.setTimeout(() => {
3056
- root.dataset.uiCalendarValue = input.value
2961
+ const observer = new MutationObserver(mutations => {
2962
+ for (const mutation of mutations) {
2963
+ if (mutation.attributeName === 'value') {
2964
+ root.dataset.uiCalendarValue = parseCalendarDate(root.getAttribute('value')) ? root.getAttribute('value') ?? '' : ''
2965
+
2966
+ input.value = root.dataset.uiCalendarValue
2967
+ }
2968
+
2969
+ if (mutation.attributeName === 'month') {
2970
+ root.dataset.uiCalendarMonth = root.getAttribute('month') ?? root.dataset.uiCalendarInitialMonth ?? ''
2971
+ }
2972
+ }
2973
+
2974
+ const activeElement = root.ownerDocument.activeElement
2975
+
2976
+ const focusedDay = activeElement && root.contains(activeElement) ?
2977
+ parseCalendarDate(activeElement.getAttribute('data-date')) :
2978
+ null
2979
+
2980
+ renderCalendar(root, focusedDay, focusedDay !== null)
2981
+ })
2982
+
2983
+ observer.observe(root, { attributes: true, attributeFilter: ['disabled', 'readonly', 'min', 'max', 'value', 'month', 'locale', 'lang', 'data-disabled', 'data-readonly', 'data-locale', 'data-ui-calendar-min', 'data-ui-calendar-max', 'data-previous-month-label', 'data-next-month-label'] })
2984
+
2985
+ input.form?.addEventListener('reset', event => {
2986
+ globalThis.setTimeout(() => {
2987
+ if (event.defaultPrevented || !root.isConnected) return
2988
+
2989
+ root.dataset.uiCalendarValue = initialValue
2990
+
2991
+ input.value = initialValue
3057
2992
 
3058
2993
  root.dataset.uiCalendarMonth = root.dataset.uiCalendarInitialMonth ?? root.dataset.uiCalendarMonth
3059
2994
 
@@ -3063,12 +2998,12 @@ const initCalendars = (scope: ParentNode): void => {
3063
2998
  }
3064
2999
  }
3065
3000
 
3066
- const formatDatePickerValue = (value: string, placeholder: string): string => {
3001
+ const formatDatePickerValue = (value: string, placeholder: string, locale: string): string => {
3067
3002
  const date = parseCalendarDate(value)
3068
3003
 
3069
3004
  if (!date) return placeholder
3070
3005
 
3071
- return new Intl.DateTimeFormat(getCalendarLocale(), {
3006
+ return new Intl.DateTimeFormat(locale, {
3072
3007
  day: 'numeric',
3073
3008
  month: 'short',
3074
3009
  timeZone: 'UTC',
@@ -3080,7 +3015,7 @@ const closeDatePicker = (root: HTMLElement, restoreFocus = false): void => {
3080
3015
  const trigger = root.querySelector<HTMLButtonElement>('[data-ui-date-picker-trigger]')
3081
3016
  const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
3082
3017
 
3083
- if (!trigger || !popover || popover.hidden) return
3018
+ if (!trigger || !popover) return
3084
3019
 
3085
3020
  popover.hidden = true
3086
3021
 
@@ -3097,9 +3032,10 @@ const syncDatePickerValue = (root: HTMLElement): void => {
3097
3032
 
3098
3033
  if (!native || !value) return
3099
3034
 
3100
- const placeholder = native.getAttribute('placeholder') || 'Choose a date'
3035
+ const defaultPlaceholder = root.dataset.chooseDateLabel || resolveLumenDateLabels(getCalendarLocale(root)).chooseDate
3036
+ const placeholder = root.dataset.placeholderDefault === 'true' ? defaultPlaceholder : native.getAttribute('placeholder') || defaultPlaceholder
3101
3037
 
3102
- value.textContent = formatDatePickerValue(native.value, placeholder)
3038
+ value.textContent = formatDatePickerValue(native.value, placeholder, getCalendarLocale(root))
3103
3039
 
3104
3040
  if (native.value) {
3105
3041
  delete root.dataset.placeholder
@@ -3118,13 +3054,16 @@ const openDatePicker = (root: HTMLElement): void => {
3118
3054
  const popover = root.querySelector<HTMLElement>('[data-ui-date-picker-popover]')
3119
3055
  const calendar = root.querySelector<HTMLElement>('[data-ui-calendar]')
3120
3056
 
3121
- if (!native || !trigger || !popover || native.disabled) return
3057
+ if (!native || !trigger || !popover || native.disabled || native.readOnly ||
3058
+ !isLumenDateBoundsValid(native.min || undefined, native.max || undefined)) return
3122
3059
 
3123
3060
  for (const other of document.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
3124
3061
  if (other !== root) closeDatePicker(other)
3125
3062
  }
3126
3063
 
3127
3064
  if (calendar) {
3065
+ calendar.dataset.locale = getCalendarLocale(root)
3066
+
3128
3067
  calendar.dataset.uiCalendarValue = native.value
3129
3068
 
3130
3069
  if (native.value) calendar.dataset.uiCalendarMonth = native.value.slice(0, 7)
@@ -3145,7 +3084,12 @@ const openDatePicker = (root: HTMLElement): void => {
3145
3084
  trigger.setAttribute('aria-expanded', 'true')
3146
3085
 
3147
3086
  window.requestAnimationFrame(() => {
3148
- calendar?.querySelector<HTMLElement>('[data-ui-calendar-day][tabindex="0"]')?.focus({ preventScroll: true })
3087
+ if (popover.hidden || native.disabled || native.readOnly) return
3088
+
3089
+ const focusTarget = calendar?.querySelector<HTMLElement>('[data-ui-calendar-day][tabindex="0"]') ??
3090
+ calendar?.querySelector<HTMLButtonElement>('button:not(:disabled)')
3091
+
3092
+ focusTarget?.focus({ preventScroll: true })
3149
3093
  })
3150
3094
  }
3151
3095
 
@@ -3169,6 +3113,14 @@ const initDatePickers = (scope: ParentNode): void => {
3169
3113
 
3170
3114
  native.tabIndex = -1
3171
3115
 
3116
+ if (native.id) {
3117
+ const controlId = native.id
3118
+
3119
+ native.id = `${controlId}-native`
3120
+
3121
+ trigger.id = controlId
3122
+ }
3123
+
3172
3124
  control.hidden = false
3173
3125
 
3174
3126
  const accessibleName = native.getAttribute('aria-label')
@@ -3181,7 +3133,41 @@ const initDatePickers = (scope: ParentNode): void => {
3181
3133
 
3182
3134
  if (describedBy) trigger.setAttribute('aria-describedby', describedBy)
3183
3135
 
3184
- syncDatePickerValue(root)
3136
+ const syncState = (): void => {
3137
+ trigger.disabled = native.disabled
3138
+
3139
+ trigger.setAttribute('aria-disabled', String(native.disabled || native.readOnly))
3140
+
3141
+ trigger.setAttribute('aria-required', String(native.required))
3142
+
3143
+ popover.setAttribute('aria-label', root.dataset.chooseDateLabel || resolveLumenDateLabels(getCalendarLocale(root)).chooseDate)
3144
+
3145
+ syncDatePickerValue(root)
3146
+ }
3147
+
3148
+ syncState()
3149
+
3150
+ const observer = new MutationObserver(() => {
3151
+ closeDatePicker(root)
3152
+
3153
+ syncState()
3154
+ })
3155
+
3156
+ observer.observe(native, { attributes: true, attributeFilter: ['disabled', 'readonly', 'required', 'value', 'min', 'max', 'placeholder'] })
3157
+
3158
+ observer.observe(root, { attributes: true, attributeFilter: ['locale', 'lang', 'data-locale', 'data-choose-date-label'] })
3159
+
3160
+ root.addEventListener('keydown', event => {
3161
+ if (event.defaultPrevented || event.isComposing) return
3162
+
3163
+ if (event.key !== 'Escape' || popover.hidden) return
3164
+
3165
+ event.preventDefault()
3166
+
3167
+ event.stopPropagation()
3168
+
3169
+ closeDatePicker(root, true)
3170
+ })
3185
3171
 
3186
3172
  trigger.addEventListener('click', () => {
3187
3173
  if (popover.hidden) openDatePicker(root)
@@ -3197,6 +3183,10 @@ const initDatePickers = (scope: ParentNode): void => {
3197
3183
  })
3198
3184
 
3199
3185
  calendarInput.addEventListener('change', () => {
3186
+ if (native.disabled || native.readOnly || !parseCalendarDate(calendarInput.value) ||
3187
+ !isLumenDateBoundsValid(native.min || undefined, native.max || undefined) ||
3188
+ (native.min && calendarInput.value < native.min) || (native.max && calendarInput.value > native.max)) return
3189
+
3200
3190
  native.value = calendarInput.value
3201
3191
 
3202
3192
  syncDatePickerValue(root)
@@ -3208,7 +3198,11 @@ const initDatePickers = (scope: ParentNode): void => {
3208
3198
  closeDatePicker(root, true)
3209
3199
  })
3210
3200
 
3211
- native.addEventListener('change', () => syncDatePickerValue(root))
3201
+ native.addEventListener('change', () => {
3202
+ closeDatePicker(root)
3203
+
3204
+ syncState()
3205
+ })
3212
3206
 
3213
3207
  native.addEventListener('invalid', event => {
3214
3208
  event.preventDefault()
@@ -3218,8 +3212,10 @@ const initDatePickers = (scope: ParentNode): void => {
3218
3212
  trigger.focus()
3219
3213
  })
3220
3214
 
3221
- native.form?.addEventListener('reset', () => {
3222
- window.setTimeout(() => {
3215
+ native.form?.addEventListener('reset', event => {
3216
+ globalThis.setTimeout(() => {
3217
+ if (event.defaultPrevented || !root.isConnected) return
3218
+
3223
3219
  syncDatePickerValue(root)
3224
3220
 
3225
3221
  closeDatePicker(root)
@@ -3254,16 +3250,22 @@ const initSchedules = (scope: ParentNode): void => {
3254
3250
 
3255
3251
  root.dataset.uiBound = 'true'
3256
3252
 
3253
+ let activeEvent: HTMLElement | undefined
3254
+
3257
3255
  for (const event of root.querySelectorAll<HTMLElement>('[data-ui-schedule-event]')) {
3258
3256
  event.draggable = event.draggable || event.dataset.uiDraggable === 'true'
3259
3257
 
3260
3258
  event.addEventListener('dragstart', dragEvent => {
3261
3259
  dragEvent.dataTransfer?.setData('text/plain', event.id || event.textContent?.trim() || '')
3262
3260
 
3261
+ activeEvent = event
3262
+
3263
3263
  root.dataset.uiDragging = 'true'
3264
3264
  })
3265
3265
 
3266
3266
  event.addEventListener('dragend', () => {
3267
+ activeEvent = undefined
3268
+
3267
3269
  delete root.dataset.uiDragging
3268
3270
  })
3269
3271
  }
@@ -3285,11 +3287,12 @@ const initSchedules = (scope: ParentNode): void => {
3285
3287
  delete dropzone.dataset.state
3286
3288
 
3287
3289
  const draggedId = event.dataTransfer?.getData('text/plain')
3288
- const dragged = draggedId ? document.getElementById(draggedId) : null
3290
+ const dragged = activeEvent
3289
3291
 
3290
- if (dragged instanceof HTMLElement) {
3291
- dropzone.append(dragged)
3292
- }
3292
+ if (!(dragged instanceof HTMLElement) ||
3293
+ dragged.closest('[data-ui-schedule]') !== root) return
3294
+
3295
+ dropzone.append(dragged)
3293
3296
 
3294
3297
  root.dispatchEvent(new CustomEvent('ui:schedule-change', {
3295
3298
  bubbles: true,
@@ -3598,43 +3601,30 @@ const initTreeCollections = (scope: ParentNode): void => {
3598
3601
  }
3599
3602
  }
3600
3603
 
3601
- const initVirtualLists = (scope: ParentNode): void => {
3602
- for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-virtual-list]')) {
3603
- if (root.dataset.uiBound === 'true') continue
3604
-
3605
- const items = [...root.children].filter((child): child is HTMLElement => child instanceof HTMLElement)
3604
+ const virtualListControllers = new Map<HTMLElement, { destroy: () => void, ownerDocument: Document }>()
3606
3605
 
3607
- if (!items.length) continue
3608
-
3609
- root.dataset.uiBound = 'true'
3610
-
3611
- const overscan = Number(root.dataset.uiOverscan ?? 4)
3612
- const itemSize = Number(root.dataset.uiItemSize ?? 44)
3613
-
3614
- const update = (): void => {
3615
- const visibleCount = Math.ceil(root.clientHeight / itemSize) + overscan
3616
- const startIndex = Math.max(0, Math.floor(root.scrollTop / itemSize) - overscan)
3617
- const endIndex = startIndex + visibleCount
3618
-
3619
- for (const [index, item] of items.entries()) {
3620
- item.hidden = index < startIndex || index > endIndex
3621
- }
3606
+ const initVirtualLists = (scope: ParentNode): void => {
3607
+ for (const [root, controller] of virtualListControllers) {
3608
+ if (!root.isConnected || root.ownerDocument !== controller.ownerDocument) {
3609
+ controller.destroy()
3622
3610
 
3623
- root.dispatchEvent(new CustomEvent('ui:virtual-list-range', {
3624
- bubbles: true,
3625
- detail: {
3626
- endIndex: Math.min(items.length - 1, endIndex),
3627
- startIndex
3628
- }
3629
- }))
3611
+ virtualListControllers.delete(root)
3630
3612
  }
3613
+ }
3631
3614
 
3632
- root.addEventListener('scroll', update, { passive: true })
3633
-
3634
- update()
3615
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-virtual-list]')) {
3616
+ if (!virtualListControllers.has(root)) virtualListControllers.set(root, {
3617
+ ...createLumenVirtualListController(root), ownerDocument: root.ownerDocument
3618
+ })
3635
3619
  }
3636
3620
  }
3637
3621
 
3622
+ document.addEventListener('astro:before-swap', () => {
3623
+ for (const controller of virtualListControllers.values()) controller.destroy()
3624
+
3625
+ virtualListControllers.clear()
3626
+ })
3627
+
3638
3628
  const initTagGroups = (scope: ParentNode): void => {
3639
3629
  for (const root of scope.querySelectorAll<HTMLElement>('.ui-tag-group, [data-ui-tag-group]')) {
3640
3630
  if (root.dataset.uiBound === 'true') continue
@@ -3673,7 +3663,9 @@ const getRichTextCommandValue = (control: HTMLElement): string | undefined => {
3673
3663
  }
3674
3664
 
3675
3665
  const syncRichTextCommandStates = (root: HTMLElement): void => {
3676
- const commandDocument = document as unknown as RichTextCommandDocument
3666
+ if (root.dataset.uiEditorNativeState === 'false') return
3667
+
3668
+ const commandDocument: RichTextCommandDocument = document
3677
3669
 
3678
3670
  for (const control of root.querySelectorAll<HTMLElement>('[data-ui-editor-command]')) {
3679
3671
  const command = control.dataset.uiEditorCommand ?? ''
@@ -3719,14 +3711,15 @@ const executeRichTextCommand = (
3719
3711
  ): boolean => {
3720
3712
  if (!command) return false
3721
3713
 
3722
- const commandDocument = document as unknown as RichTextCommandDocument
3723
- let executed = false
3714
+ const commandDocument: RichTextCommandDocument = document
3715
+
3716
+ const executed = executeLumenRichTextCommand(root, { command, ...(value === undefined ? {} : { value }) }, () => {
3717
+ if (typeof commandDocument.execCommand !== 'function') return false
3724
3718
 
3725
- if (typeof commandDocument.execCommand === 'function') {
3726
- executed = value === undefined ?
3719
+ return value === undefined ?
3727
3720
  commandDocument.execCommand(command) :
3728
3721
  commandDocument.execCommand(command, false, value)
3729
- }
3722
+ })
3730
3723
 
3731
3724
  const detail: LumenRichTextCommandDetail = {
3732
3725
  command,
@@ -3842,41 +3835,6 @@ const normalizeThemeBuilderHex = (value: string): string | null => {
3842
3835
  return /^[\da-f]{6}$/i.test(hex) ? `#${hex.toLowerCase()}` : null
3843
3836
  }
3844
3837
 
3845
- const hexToThemeBuilderHsl = (value: string): { hue: number, value: string } | null => {
3846
- const normalized = normalizeThemeBuilderHex(value)
3847
-
3848
- if (!normalized) return null
3849
-
3850
- const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
3851
- const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
3852
- const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
3853
- const max = Math.max(red, green, blue)
3854
- const min = Math.min(red, green, blue)
3855
- const lightness = (max + min) / 2
3856
- const delta = max - min
3857
- let hue = 0
3858
- let saturation = 0
3859
-
3860
- if (delta !== 0) {
3861
- saturation = delta / (1 - Math.abs(2 * lightness - 1))
3862
-
3863
- if (max === red) {
3864
- hue = 60 * (((green - blue) / delta) % 6)
3865
- } else if (max === green) {
3866
- hue = 60 * ((blue - red) / delta + 2)
3867
- } else {
3868
- hue = 60 * ((red - green) / delta + 4)
3869
- }
3870
- }
3871
-
3872
- const normalizedHue = normalizeThemeBuilderHue(hue)
3873
-
3874
- return {
3875
- hue: normalizedHue,
3876
- value: `${normalizedHue} ${Math.round(saturation * 100)}% ${Math.round(lightness * 100)}%`
3877
- }
3878
- }
3879
-
3880
3838
  const setThemeBuilderHexValidity = (input: HTMLInputElement, isValid: boolean): void => {
3881
3839
  if (isValid) {
3882
3840
  input.removeAttribute('aria-invalid')
@@ -4067,8 +4025,19 @@ const initThemeBuilders = (scope: ParentNode): void => {
4067
4025
  const exportFormatButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-export-format]')]
4068
4026
  const modeButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-mode]')]
4069
4027
  const schemeButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-scheme]')]
4028
+ const presetButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-theme-preset]')]
4029
+ const initialPreset = root.getAttribute('data-ui-theme-preset')
4030
+ let currentPreset = initialPreset === 'custom' ? null : initialPreset
4031
+
4032
+ for (const button of presetButtons) {
4033
+ button.setAttribute('aria-pressed', String(button.getAttribute('data-ui-theme-preset') === (currentPreset ?? 'custom')))
4034
+ }
4035
+
4036
+ setThemeBuilderPressedState(presetButtons, 'data-ui-theme-preset', currentPreset ?? 'custom')
4070
4037
 
4071
4038
  const hasTokenControls = Boolean(
4039
+ presetButtons.length || currentPreset ||
4040
+ root.querySelector('[data-ui-theme-radius-scale], [data-ui-theme-spacing-scale], [data-ui-theme-border-width]') ||
4072
4041
  brandHue ||
4073
4042
  brandHueNumber ||
4074
4043
  accentHue ||
@@ -4144,29 +4113,20 @@ const initThemeBuilders = (scope: ParentNode): void => {
4144
4113
  )
4145
4114
  )
4146
4115
 
4147
- const primaryValue = primaryHex?.value ?? primaryColor?.value
4148
- const secondaryValue = secondaryHex?.value ?? secondaryColor?.value
4149
- const primary = primaryValue ? hexToThemeBuilderHsl(primaryValue) : null
4150
- const secondary = secondaryValue ? hexToThemeBuilderHsl(secondaryValue) : null
4151
- const baseHue = mode === 'manual' ? primary?.hue ?? hue : hue
4152
- const baseAccentHue = mode === 'manual' ? secondary?.hue ?? nextAccentHue : nextAccentHue
4153
-
4154
- const tokens = createThemeFromHue(baseHue, {
4155
- accentHue: baseAccentHue,
4156
- scheme: currentScheme
4116
+ const result = createThemeBuilderTokens({
4117
+ hue,
4118
+ accentHue: nextAccentHue,
4119
+ mode,
4120
+ scheme: currentScheme,
4121
+ primaryColor: primaryHex?.value ?? primaryColor?.value ?? null,
4122
+ secondaryColor: secondaryHex?.value ?? secondaryColor?.value ?? null,
4123
+ preset: currentPreset,
4124
+ radiusScale: root.querySelector<HTMLInputElement>('[data-ui-theme-radius-scale]')?.value ?? null,
4125
+ spacingScale: root.querySelector<HTMLInputElement>('[data-ui-theme-spacing-scale]')?.value ?? null,
4126
+ borderWidth: root.querySelector<HTMLInputElement>('[data-ui-theme-border-width]')?.value ?? null
4157
4127
  })
4158
4128
 
4159
- if (mode === 'manual') {
4160
- if (primary) {
4161
- tokens.brand = primary.value
4162
-
4163
- tokens['brand-solid'] = primary.value
4164
- }
4165
-
4166
- if (secondary) {
4167
- tokens.accent = secondary.value
4168
- }
4169
- }
4129
+ const tokens = result.tokens
4170
4130
 
4171
4131
  currentTokens = tokens
4172
4132
 
@@ -4182,16 +4142,14 @@ const initThemeBuilders = (scope: ParentNode): void => {
4182
4142
 
4183
4143
  root.dispatchEvent(new CustomEvent('ui:theme-change', {
4184
4144
  bubbles: true,
4185
- detail: {
4186
- accentHue: baseAccentHue,
4187
- hue: baseHue,
4188
- mode,
4189
- scheme: currentScheme,
4190
- tokens
4191
- }
4145
+ detail: result
4192
4146
  }))
4193
4147
  }
4194
4148
 
4149
+ for (const input of root.querySelectorAll<HTMLInputElement>('[data-ui-theme-radius-scale], [data-ui-theme-spacing-scale], [data-ui-theme-border-width]')) {
4150
+ input.addEventListener('input', () => update())
4151
+ }
4152
+
4195
4153
  bindSyncedThemeBuilderHueInputs(brandHue, brandHueNumber, update)
4196
4154
 
4197
4155
  bindSyncedThemeBuilderHueInputs(accentHue, accentHueNumber, update)
@@ -4232,6 +4190,16 @@ const initThemeBuilders = (scope: ParentNode): void => {
4232
4190
  })
4233
4191
  }
4234
4192
 
4193
+ for (const button of presetButtons) {
4194
+ button.addEventListener('click', () => {
4195
+ currentPreset = button.getAttribute('data-ui-theme-preset') === 'custom' ? null : coerceThemePreset(button.getAttribute('data-ui-theme-preset'))
4196
+
4197
+ setThemeBuilderPressedState(presetButtons, 'data-ui-theme-preset', currentPreset ?? 'custom')
4198
+
4199
+ update()
4200
+ })
4201
+ }
4202
+
4235
4203
  for (const button of schemeButtons) {
4236
4204
  button.addEventListener('click', () => {
4237
4205
  currentScheme = coerceThemeBuilderScheme(button.getAttribute('data-ui-theme-scheme'))
@@ -4344,6 +4312,8 @@ const initContextMenus = (scope: ParentNode): void => {
4344
4312
  })
4345
4313
 
4346
4314
  menu.addEventListener('keydown', event => {
4315
+ if (event.defaultPrevented || event.isComposing) return
4316
+
4347
4317
  if (event.key === 'Escape') {
4348
4318
  closeContextMenu(menu)
4349
4319
 
@@ -4360,9 +4330,13 @@ const initContextMenus = (scope: ParentNode): void => {
4360
4330
 
4361
4331
  event.preventDefault()
4362
4332
 
4363
- const currentIndex = items.indexOf(document.activeElement as HTMLElement)
4333
+ const currentIndex = items.findIndex(item => item === menu.ownerDocument.activeElement)
4364
4334
 
4365
- items[getLoopedIndex(event.key, currentIndex, items.length, ['ArrowDown'])]?.focus()
4335
+ const nextIndex = currentIndex < 0 && event.key === 'ArrowUp' ?
4336
+ items.length - 1 :
4337
+ getLoopedIndex(event.key, currentIndex, items.length, ['ArrowDown'])
4338
+
4339
+ items[nextIndex]?.focus()
4366
4340
  })
4367
4341
 
4368
4342
  menu.addEventListener('click', event => {
@@ -4478,6 +4452,9 @@ type ToastApi = {
4478
4452
  const defaultToastDuration = 5000
4479
4453
  const defaultToastMax = 5
4480
4454
  const noop = (): void => undefined
4455
+ // Tracks the Document the toast API is bound to, since Astro's HTML swap resets any
4456
+ // documentElement dataset flag on navigation even though these document listeners persist.
4457
+ let toastDocumentApiDocument: Document | null = null
4481
4458
 
4482
4459
  const toastTimers = new WeakMap<HTMLElement, {
4483
4460
  cleanup: () => void
@@ -4567,39 +4544,68 @@ const scheduleToastDismiss = (toast: HTMLElement, duration: number): void => {
4567
4544
  timer: 0
4568
4545
  }
4569
4546
 
4547
+ let hovered = false
4548
+ let paused = false
4549
+
4570
4550
  const start = (): void => {
4551
+ paused = false
4552
+
4571
4553
  timerState.startedAt = Date.now()
4572
4554
 
4573
4555
  timerState.timer = window.setTimeout(() => dismissToast(toast), timerState.remaining)
4574
4556
  }
4575
4557
 
4576
4558
  const pause = (): void => {
4559
+ if (paused) return
4560
+
4561
+ paused = true
4562
+
4577
4563
  window.clearTimeout(timerState.timer)
4578
4564
 
4579
4565
  timerState.remaining = Math.max(0, timerState.remaining - (Date.now() - timerState.startedAt))
4580
4566
  }
4581
4567
 
4582
4568
  const resume = (): void => {
4583
- if (timerState.remaining > 0) start()
4569
+ if (!paused || hovered || toast.contains(document.activeElement)) return
4570
+
4571
+ start()
4572
+ }
4573
+
4574
+ const enter = (): void => {
4575
+ hovered = true
4576
+
4577
+ pause()
4578
+ }
4579
+
4580
+ const leave = (): void => {
4581
+ hovered = false
4582
+
4583
+ resume()
4584
+ }
4585
+
4586
+ const blur = (event: FocusEvent): void => {
4587
+ if (event.relatedTarget instanceof Node && toast.contains(event.relatedTarget)) return
4588
+
4589
+ resume()
4584
4590
  }
4585
4591
 
4586
4592
  timerState.cleanup = () => {
4587
- toast.removeEventListener('mouseenter', pause)
4593
+ toast.removeEventListener('mouseenter', enter)
4588
4594
 
4589
- toast.removeEventListener('mouseleave', resume)
4595
+ toast.removeEventListener('mouseleave', leave)
4590
4596
 
4591
4597
  toast.removeEventListener('focusin', pause)
4592
4598
 
4593
- toast.removeEventListener('focusout', resume)
4599
+ toast.removeEventListener('focusout', blur)
4594
4600
  }
4595
4601
 
4596
- toast.addEventListener('mouseenter', pause)
4602
+ toast.addEventListener('mouseenter', enter)
4597
4603
 
4598
- toast.addEventListener('mouseleave', resume)
4604
+ toast.addEventListener('mouseleave', leave)
4599
4605
 
4600
4606
  toast.addEventListener('focusin', pause)
4601
4607
 
4602
- toast.addEventListener('focusout', resume)
4608
+ toast.addEventListener('focusout', blur)
4603
4609
 
4604
4610
  toastTimers.set(toast, timerState)
4605
4611
 
@@ -4683,14 +4689,10 @@ const setToastContent = (toast: HTMLElement, detail: ToastDetail): void => {
4683
4689
  toast.append(dismiss)
4684
4690
  }
4685
4691
 
4686
- const initToasts = (): void => {
4687
- if (document.documentElement.dataset.uiToastsBound === 'true') return
4688
-
4689
- document.documentElement.dataset.uiToastsBound = 'true'
4690
-
4692
+ const bindToastDocumentApi = (): void => {
4691
4693
  const createToast = (detail: ToastDetail): string => {
4692
4694
  const viewport = getToastViewport(detail.placement)
4693
- const id = detail.id ?? `ui-toast-${crypto.randomUUID()}`
4695
+ const id = detail.id ?? nextRuntimeId('ui-toast', document)
4694
4696
  const duration = detail.duration ?? defaultToastDuration
4695
4697
  const max = detail.max ?? Number(viewport.dataset.uiToastMax ?? defaultToastMax)
4696
4698
  const existingToast = document.getElementById(id)
@@ -4713,9 +4715,13 @@ const initToasts = (): void => {
4713
4715
 
4714
4716
  toast.dataset.uiToast = ''
4715
4717
 
4718
+ toast.dataset.uiBound = 'true'
4719
+
4716
4720
  toast.dataset.state = 'open'
4717
4721
 
4718
4722
  toast.addEventListener('keydown', event => {
4723
+ if (event.defaultPrevented || event.isComposing) return
4724
+
4719
4725
  if (event.key !== 'Escape') return
4720
4726
 
4721
4727
  event.preventDefault()
@@ -4768,24 +4774,6 @@ const initToasts = (): void => {
4768
4774
 
4769
4775
  ;(window as Window & { LumenToast?: ToastApi }).LumenToast = api
4770
4776
 
4771
- for (const viewport of document.querySelectorAll<HTMLElement>('[data-ui-toast-viewport]')) {
4772
- viewport.dataset.placement = getToastPlacement(viewport.dataset.placement)
4773
-
4774
- viewport.setAttribute('aria-live', viewport.getAttribute('aria-live') ?? 'polite')
4775
-
4776
- viewport.setAttribute('aria-atomic', viewport.getAttribute('aria-atomic') ?? 'false')
4777
- }
4778
-
4779
- for (const toast of document.querySelectorAll<HTMLElement>('[data-ui-toast], .ui-toast')) {
4780
- toast.addEventListener('keydown', event => {
4781
- if (event.key !== 'Escape') return
4782
-
4783
- event.preventDefault()
4784
-
4785
- dismissToast(toast)
4786
- })
4787
- }
4788
-
4789
4777
  document.addEventListener('ui:toast', event => {
4790
4778
  createToast(event instanceof CustomEvent ? event.detail as ToastDetail : {})
4791
4779
  })
@@ -4803,63 +4791,35 @@ const initToasts = (): void => {
4803
4791
  })
4804
4792
  }
4805
4793
 
4806
- const initFileUploads = (scope: ParentNode): void => {
4807
- for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-file-upload]')) {
4808
- if (root.dataset.uiBound === 'true') continue
4809
-
4810
- const input = root.querySelector<HTMLInputElement>('[data-ui-file-upload-input]')
4811
-
4812
- if (!input) continue
4813
-
4814
- root.dataset.uiBound = 'true'
4815
-
4816
- const files = root.querySelector<HTMLElement>('[data-ui-file-upload-files]')
4817
-
4818
- const renderFiles = (): void => {
4819
- const selectedFiles = input.files ? [...input.files] : []
4820
-
4821
- root.dataset.state = selectedFiles.length > 0 ? 'selected' : 'idle'
4794
+ const initToasts = (scope: ParentNode): void => {
4795
+ if (toastDocumentApiDocument !== document) {
4796
+ toastDocumentApiDocument = document
4822
4797
 
4823
- if (!files) return
4824
-
4825
- if (selectedFiles.length === 1) {
4826
- files.textContent = selectedFiles[0]?.name ?? ''
4827
- } else {
4828
- files.textContent = selectedFiles.length > 1 ?
4829
- `${selectedFiles.length} files selected` :
4830
- ''
4831
- }
4832
- }
4798
+ bindToastDocumentApi()
4799
+ }
4833
4800
 
4834
- input.addEventListener('change', renderFiles)
4801
+ for (const viewport of scope.querySelectorAll<HTMLElement>('[data-ui-toast-viewport]')) {
4802
+ viewport.dataset.placement = getToastPlacement(viewport.dataset.placement)
4835
4803
 
4836
- root.addEventListener('dragover', event => {
4837
- if (input.disabled) return
4804
+ viewport.setAttribute('aria-live', viewport.getAttribute('aria-live') ?? 'polite')
4838
4805
 
4839
- event.preventDefault()
4806
+ viewport.setAttribute('aria-atomic', viewport.getAttribute('aria-atomic') ?? 'false')
4807
+ }
4840
4808
 
4841
- root.dataset.state = 'drag-over'
4842
- })
4809
+ for (const toast of scope.querySelectorAll<HTMLElement>('[data-ui-toast], .ui-toast')) {
4810
+ if (toast.dataset.uiBound === 'true') continue
4843
4811
 
4844
- root.addEventListener('dragleave', event => {
4845
- if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return
4812
+ toast.dataset.uiBound = 'true'
4846
4813
 
4847
- root.dataset.state = input.files?.length ? 'selected' : 'idle'
4848
- })
4814
+ toast.addEventListener('keydown', event => {
4815
+ if (event.defaultPrevented || event.isComposing) return
4849
4816
 
4850
- root.addEventListener('drop', event => {
4851
- if (input.disabled) return
4817
+ if (event.key !== 'Escape') return
4852
4818
 
4853
4819
  event.preventDefault()
4854
4820
 
4855
- if (event.dataTransfer) {
4856
- input.files = event.dataTransfer.files
4857
-
4858
- input.dispatchEvent(new Event('change', { bubbles: true }))
4859
- }
4821
+ dismissToast(toast)
4860
4822
  })
4861
-
4862
- renderFiles()
4863
4823
  }
4864
4824
  }
4865
4825
 
@@ -5021,6 +4981,10 @@ const initMentions = (scope: ParentNode): void => {
5021
4981
  const options = [...list.querySelectorAll<HTMLButtonElement>('[data-ui-mentions-option]')]
5022
4982
  let activeIndex = -1
5023
4983
 
4984
+ for (const option of options) {
4985
+ option.tabIndex = -1
4986
+ }
4987
+
5024
4988
  const setActive = (index: number): void => {
5025
4989
  const visibleOptions = options.filter(option => !option.hidden)
5026
4990
 
@@ -5094,6 +5058,8 @@ const initMentions = (scope: ParentNode): void => {
5094
5058
  input.addEventListener('keydown', event => {
5095
5059
  const visibleOptions = options.filter(option => !option.hidden)
5096
5060
 
5061
+ if (event.defaultPrevented || event.isComposing) return
5062
+
5097
5063
  if (event.key === 'Escape' && !list.hidden) {
5098
5064
  event.preventDefault()
5099
5065
 
@@ -5113,7 +5079,9 @@ const initMentions = (scope: ParentNode): void => {
5113
5079
  }
5114
5080
  })
5115
5081
 
5116
- input.addEventListener('blur', () => window.setTimeout(close, 120))
5082
+ input.addEventListener('blur', () => window.setTimeout(() => {
5083
+ if (!root.contains(root.ownerDocument.activeElement)) close()
5084
+ }, 120))
5117
5085
 
5118
5086
  for (const option of options) {
5119
5087
  option.addEventListener('mousedown', event => {
@@ -5173,7 +5141,15 @@ const initSpeedDials = (scope: ParentNode): void => {
5173
5141
  })
5174
5142
 
5175
5143
  trigger.addEventListener('keydown', event => {
5176
- if (event.key === 'Escape') closeSpeedDial(root)
5144
+ if (event.defaultPrevented || event.isComposing) return
5145
+
5146
+ if (event.key === 'Escape' && root.dataset.state === 'open') {
5147
+ event.preventDefault()
5148
+
5149
+ closeSpeedDial(root)
5150
+
5151
+ return
5152
+ }
5177
5153
 
5178
5154
  if (['ArrowDown', 'ArrowRight'].includes(event.key)) {
5179
5155
  event.preventDefault()
@@ -5197,6 +5173,8 @@ const initSpeedDials = (scope: ParentNode): void => {
5197
5173
  })
5198
5174
 
5199
5175
  actions.addEventListener('keydown', event => {
5176
+ if (event.defaultPrevented || event.isComposing) return
5177
+
5200
5178
  if (event.key === 'Escape') {
5201
5179
  event.preventDefault()
5202
5180
 
@@ -5382,6 +5360,18 @@ const initOptionalControllers = (scope: ParentNode): void => {
5382
5360
  })
5383
5361
  }
5384
5362
 
5363
+ initOptionalMediaControllers(scope).catch(reportError)
5364
+
5365
+ if (scope.querySelector('[data-ui-tab-indicator], [data-ui-chart-motion], [data-ui-motion-group], [data-ui-visual-effect="spotlight"], [data-ui-prompt-composer], [data-ui-approval-card]')) {
5366
+ import('./controllers/visual-interactions.js')
5367
+ .then(({ initVisualInteractionControllers }) => initVisualInteractionControllers(scope))
5368
+ .catch(reportError)
5369
+ }
5370
+
5371
+ if (scope.querySelector('[data-ui-chart-interactive]')) {
5372
+ import('./controllers/charts.js').then(({ initChartControllers }) => initChartControllers(scope)).catch(reportError)
5373
+ }
5374
+
5385
5375
  if (scope.querySelector(
5386
5376
  '[data-ui-back-to-top], [data-ui-scroll-reveal], [data-ui-reveal-group], [data-ui-animated-number]'
5387
5377
  )) {
@@ -5390,6 +5380,18 @@ const initOptionalControllers = (scope: ParentNode): void => {
5390
5380
  .catch(reportError)
5391
5381
  }
5392
5382
 
5383
+ if (scope.querySelector('[data-ui-attachment-preview]')) {
5384
+ import('./controllers/attachments.js')
5385
+ .then(({ initAttachmentPreviewControllers }) => initAttachmentPreviewControllers(scope))
5386
+ .catch(reportError)
5387
+ }
5388
+
5389
+ if (scope.querySelector('[data-ui-chart-activation]:not([data-ui-chart-adapter="react"]):not([data-ui-chart-adapter="elements"])')) {
5390
+ import('./controllers/chart-activation.js')
5391
+ .then(({ initChartActivationControllers }) => initChartActivationControllers(scope))
5392
+ .catch(reportError)
5393
+ }
5394
+
5393
5395
  if (scope.querySelector('[data-ui-phone-input]')) {
5394
5396
  import('./controllers/phone-input.js')
5395
5397
  .then(({ initPhoneInputControllers }) => initPhoneInputControllers(scope))
@@ -5450,7 +5452,11 @@ const initUiPrimitives = (scope: ParentNode = document): void => {
5450
5452
 
5451
5453
  initDatePickers(scope)
5452
5454
 
5453
- initCodeCopy(scope)
5455
+ initAmountFields(scope)
5456
+
5457
+ initMessageFeeds(scope)
5458
+
5459
+ initCodeRegions(scope)
5454
5460
 
5455
5461
  initCopyButtons(scope)
5456
5462
 
@@ -5480,8 +5486,6 @@ const initUiPrimitives = (scope: ParentNode = document): void => {
5480
5486
 
5481
5487
  initGlassTracking(scope)
5482
5488
 
5483
- initFileUploads(scope)
5484
-
5485
5489
  initTours(scope)
5486
5490
 
5487
5491
  initTransfers(scope)
@@ -5497,7 +5501,7 @@ const initUiPrimitives = (scope: ParentNode = document): void => {
5497
5501
 
5498
5502
  initOptionalControllers(scope)
5499
5503
 
5500
- initToasts()
5504
+ initToasts(scope)
5501
5505
  }
5502
5506
 
5503
5507
  const lumenWindow = window as Window & { LumenInitUiPrimitives?: (scope?: ParentNode) => void }
@@ -5550,30 +5554,49 @@ if (document.documentElement.dataset.uiPrimitivesGlobalBound !== 'true') {
5550
5554
  })
5551
5555
 
5552
5556
  document.addEventListener('keydown', event => {
5553
- if (event.key !== 'Escape') return
5557
+ if (event.defaultPrevented || event.isComposing || event.key !== 'Escape') return
5554
5558
 
5555
- for (const root of document.querySelectorAll<HTMLElement>(disclosureSelector)) {
5556
- closeDisclosure(root)
5557
- }
5559
+ // The first open owner in the event path is the innermost active popup.
5560
+ for (const target of event.composedPath()) {
5561
+ if (!(target instanceof HTMLElement)) continue
5558
5562
 
5559
- for (const root of document.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
5560
- closeCombobox(root)
5561
- }
5563
+ if (target.matches(disclosureSelector)) {
5564
+ const trigger = target.querySelector<HTMLElement>('[data-ui-trigger]')
5562
5565
 
5563
- for (const root of document.querySelectorAll<HTMLElement>('[data-ui-select]')) {
5564
- closeSelect(root)
5565
- }
5566
+ if (trigger?.getAttribute('aria-expanded') !== 'true') continue
5566
5567
 
5567
- for (const root of document.querySelectorAll<HTMLElement>('[data-ui-date-picker]')) {
5568
- closeDatePicker(root, root.contains(document.activeElement))
5569
- }
5568
+ event.preventDefault()
5570
5569
 
5571
- for (const menu of document.querySelectorAll<HTMLElement>('[data-ui-context-menu][data-state="open"]')) {
5572
- closeContextMenu(menu)
5573
- }
5570
+ closeDisclosure(target)
5574
5571
 
5575
- for (const root of document.querySelectorAll<HTMLElement>('[data-ui-speed-dial][data-state="open"]')) {
5576
- closeSpeedDial(root, root.contains(document.activeElement))
5572
+ trigger.focus({ preventScroll: true })
5573
+
5574
+ return
5575
+ }
5576
+
5577
+ if (target.matches('[data-ui-date-picker]') && target.querySelector('[aria-expanded="true"]')) {
5578
+ event.preventDefault()
5579
+
5580
+ closeDatePicker(target, true)
5581
+
5582
+ return
5583
+ }
5584
+
5585
+ if (target.matches('[data-ui-context-menu][data-state="open"]')) {
5586
+ event.preventDefault()
5587
+
5588
+ closeContextMenu(target)
5589
+
5590
+ return
5591
+ }
5592
+
5593
+ if (target.matches('[data-ui-speed-dial][data-state="open"]')) {
5594
+ event.preventDefault()
5595
+
5596
+ closeSpeedDial(target, true)
5597
+
5598
+ return
5599
+ }
5577
5600
  }
5578
5601
  })
5579
5602