@santi020k/lumen-astro 0.1.0 → 0.2.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 (124) hide show
  1. package/README.md +5 -0
  2. package/components/Accordion.astro +8 -2
  3. package/components/Affix.astro +1 -1
  4. package/components/Agenda.astro +1 -1
  5. package/components/Alert.astro +1 -1
  6. package/components/AlertDialog.astro +1 -1
  7. package/components/Anchor.astro +1 -1
  8. package/components/AnimatedLogo.astro +1 -1
  9. package/components/AnimatedNumber.astro +1 -1
  10. package/components/AnimatedPortrait.astro +1 -1
  11. package/components/AspectRatio.astro +1 -1
  12. package/components/Attachment.astro +1 -1
  13. package/components/Autocomplete.astro +1 -1
  14. package/components/Avatar.astro +1 -1
  15. package/components/BackToTop.astro +1 -1
  16. package/components/Badge.astro +1 -1
  17. package/components/Breadcrumb.astro +1 -1
  18. package/components/Bubble.astro +1 -1
  19. package/components/Button.astro +1 -1
  20. package/components/ButtonGroup.astro +1 -1
  21. package/components/ButtonLink.astro +5 -4
  22. package/components/Calendar.astro +1 -1
  23. package/components/Callout.astro +1 -1
  24. package/components/Card.astro +3 -2
  25. package/components/Carousel.astro +1 -1
  26. package/components/Cascader.astro +1 -1
  27. package/components/Chart.astro +1 -1
  28. package/components/Checkbox.astro +1 -1
  29. package/components/Code.astro +5 -2
  30. package/components/Collapsible.astro +1 -1
  31. package/components/ColorPicker.astro +1 -1
  32. package/components/Combobox.astro +1 -1
  33. package/components/Command.astro +1 -1
  34. package/components/ContextMenu.astro +1 -1
  35. package/components/CoverImage.astro +1 -1
  36. package/components/DataTable.astro +1 -1
  37. package/components/DatePicker.astro +1 -1
  38. package/components/DateRangePicker.astro +1 -1
  39. package/components/Descriptions.astro +1 -1
  40. package/components/Dialog.astro +1 -1
  41. package/components/Direction.astro +1 -1
  42. package/components/Drawer.astro +1 -1
  43. package/components/DropdownMenu.astro +1 -1
  44. package/components/Empty.astro +1 -1
  45. package/components/Eyebrow.astro +1 -1
  46. package/components/Field.astro +1 -1
  47. package/components/FileUpload.astro +1 -1
  48. package/components/FloatingBadge.astro +1 -1
  49. package/components/FormattedDate.astro +1 -1
  50. package/components/GradientDivider.astro +1 -1
  51. package/components/HoverCard.astro +1 -1
  52. package/components/Icon.astro +5 -1
  53. package/components/Image.astro +1 -1
  54. package/components/Input.astro +1 -1
  55. package/components/InputGroup.astro +1 -1
  56. package/components/InputOTP.astro +1 -1
  57. package/components/Item.astro +1 -1
  58. package/components/Kbd.astro +1 -1
  59. package/components/Label.astro +1 -1
  60. package/components/LanguageToggle.astro +1 -1
  61. package/components/Link.astro +16 -2
  62. package/components/Marker.astro +1 -1
  63. package/components/Mentions.astro +1 -1
  64. package/components/Menubar.astro +1 -1
  65. package/components/Message.astro +1 -1
  66. package/components/MessageScroller.astro +1 -1
  67. package/components/NativeSelect.astro +1 -1
  68. package/components/NavigationMenu.astro +5 -1
  69. package/components/NumberField.astro +1 -1
  70. package/components/Pagination.astro +1 -1
  71. package/components/Particles.astro +1 -1
  72. package/components/PhoneInput.astro +1 -1
  73. package/components/Pill.astro +13 -8
  74. package/components/Popconfirm.astro +1 -1
  75. package/components/Popover.astro +1 -1
  76. package/components/Progress.astro +1 -1
  77. package/components/Prose.astro +1 -1
  78. package/components/QRCode.astro +1 -1
  79. package/components/RadioGroup.astro +1 -1
  80. package/components/Resizable.astro +1 -1
  81. package/components/RevealGroup.astro +1 -1
  82. package/components/RichTextEditor.astro +1 -1
  83. package/components/Schedule.astro +1 -1
  84. package/components/ScrollArea.astro +1 -1
  85. package/components/ScrollReveal.astro +1 -1
  86. package/components/SearchField.astro +1 -1
  87. package/components/Segmented.astro +1 -1
  88. package/components/Select.astro +1 -1
  89. package/components/Separator.astro +1 -1
  90. package/components/Sheet.astro +1 -1
  91. package/components/Sidebar.astro +5 -1
  92. package/components/Skeleton.astro +1 -1
  93. package/components/SkipLink.astro +1 -1
  94. package/components/Slider.astro +1 -1
  95. package/components/Sonner.astro +1 -1
  96. package/components/SpeedDial.astro +1 -1
  97. package/components/Spinner.astro +1 -1
  98. package/components/Stat.astro +1 -1
  99. package/components/Stepper.astro +1 -1
  100. package/components/Switch.astro +1 -1
  101. package/components/Table.astro +1 -1
  102. package/components/Tabs.astro +7 -1
  103. package/components/TagGroup.astro +1 -1
  104. package/components/Textarea.astro +1 -1
  105. package/components/ThemeBuilder.astro +1 -1
  106. package/components/ThemeToggle.astro +13 -1
  107. package/components/TimeField.astro +1 -1
  108. package/components/Toast.astro +1 -1
  109. package/components/Toggle.astro +12 -4
  110. package/components/ToggleGroup.astro +1 -1
  111. package/components/Toolbar.astro +1 -1
  112. package/components/Tooltip.astro +1 -1
  113. package/components/Tour.astro +1 -1
  114. package/components/Transfer.astro +1 -1
  115. package/components/Tree.astro +1 -1
  116. package/components/TreeGrid.astro +1 -1
  117. package/components/TreeSelect.astro +1 -1
  118. package/components/Typography.astro +1 -1
  119. package/components/VirtualList.astro +1 -1
  120. package/components/Watermark.astro +1 -1
  121. package/package.json +5 -4
  122. package/runtime/UIPrimitives.astro +255 -146
  123. package/runtime/controllers/motion.ts +145 -0
  124. package/styles/layers.css +1 -0
@@ -92,6 +92,7 @@
92
92
  CSS.supports('position-area: block-end')
93
93
 
94
94
  let uiAnchorCount = 0
95
+ let uiTabsCount = 0
95
96
  const nextAnchorName = (): string => `--ui-anchor-${++uiAnchorCount}`
96
97
 
97
98
  const isElementVisible = (element: HTMLElement): boolean =>
@@ -612,15 +613,47 @@
612
613
  root.dataset.uiBound = 'true'
613
614
 
614
615
  const tabs = [...root.querySelectorAll<HTMLElement>('[role="tab"]')]
616
+ .filter(tab => tab.closest('[data-ui-tabs]') === root)
617
+
615
618
  const panels = [...root.querySelectorAll<HTMLElement>('[role="tabpanel"]')]
619
+ .filter(panel => panel.closest('[data-ui-tabs]') === root)
616
620
 
617
621
  if (!tabs.length || !panels.length) continue
618
622
 
619
- const activate = (tab: HTMLElement): void => {
620
- for (const item of tabs) {
623
+ const rootId = root.id || `ui-tabs-${++uiTabsCount}`
624
+
625
+ root.id = rootId
626
+
627
+ for (const [index, tab] of tabs.entries()) {
628
+ const panel = panels.find(candidate => (
629
+ candidate.dataset.value && candidate.dataset.value === tab.dataset.value
630
+ )) ?? panels[index]
631
+
632
+ const value = tab.dataset.value ?? panel?.dataset.value ?? String(index)
633
+
634
+ tab.dataset.value = value
635
+
636
+ if (panel) {
637
+ panel.dataset.value = value
638
+
639
+ tab.id ||= `${rootId}-tab-${index + 1}`
640
+
641
+ panel.id ||= `${rootId}-panel-${index + 1}`
642
+
643
+ tab.setAttribute('aria-controls', panel.id)
644
+
645
+ panel.setAttribute('aria-labelledby', tab.id)
646
+ }
647
+ }
648
+
649
+ const activate = (tab: HTMLElement, emit = true): void => {
650
+ const value = tab.dataset.value ?? ''
651
+ const wasSelected = tab.getAttribute('aria-selected') === 'true'
652
+
653
+ for (const [index, item] of tabs.entries()) {
621
654
  const selected = item === tab
622
655
  const panelId = item.getAttribute('aria-controls')
623
- const panel = panelId ? document.getElementById(panelId) : null
656
+ const panel = panelId ? document.getElementById(panelId) : panels[index]
624
657
 
625
658
  item.setAttribute('aria-selected', String(selected))
626
659
 
@@ -630,11 +663,57 @@
630
663
  panel.hidden = !selected
631
664
  }
632
665
  }
666
+
667
+ root.dataset.value = value
668
+
669
+ if (!emit || wasSelected) return
670
+
671
+ const storageKey = root.dataset.storageKey
672
+
673
+ if (storageKey) {
674
+ try {
675
+ localStorage.setItem(storageKey, value)
676
+ } catch {
677
+ // Storage can be unavailable in privacy modes; tab selection should still work.
678
+ }
679
+
680
+ for (const otherRoot of document.querySelectorAll<HTMLElement>('[data-ui-tabs][data-storage-key]')) {
681
+ if (otherRoot === root || otherRoot.dataset.storageKey !== storageKey || otherRoot.dataset.value === value) {
682
+ continue
683
+ }
684
+
685
+ const matchingTab = [...otherRoot.querySelectorAll<HTMLElement>('[role="tab"]')]
686
+ .find(candidate => candidate.closest('[data-ui-tabs]') === otherRoot && candidate.dataset.value === value)
687
+
688
+ matchingTab?.click()
689
+ }
690
+ }
691
+
692
+ root.dispatchEvent(new CustomEvent('ui:tabs-change', {
693
+ bubbles: true,
694
+ detail: { storageKey, value }
695
+ }))
633
696
  }
634
697
 
635
- const initiallySelected = tabs.find(tab => tab.getAttribute('aria-selected') === 'true') ?? tabs[0]
698
+ let storedValue: string | null = null
636
699
 
637
- if (initiallySelected) activate(initiallySelected)
700
+ if (root.dataset.storageKey) {
701
+ try {
702
+ storedValue = localStorage.getItem(root.dataset.storageKey)
703
+ } catch {
704
+ // Storage can be unavailable in privacy modes.
705
+ }
706
+ }
707
+
708
+ const initialValue = storedValue ?? root.dataset.initialValue
709
+
710
+ const initiallySelected = (
711
+ initialValue
712
+ ? tabs.find(tab => tab.dataset.value === initialValue)
713
+ : undefined
714
+ ) ?? tabs.find(tab => tab.getAttribute('aria-selected') === 'true') ?? tabs[0]
715
+
716
+ if (initiallySelected) activate(initiallySelected, false)
638
717
 
639
718
  for (const [index, tab] of tabs.entries()) {
640
719
  tab.addEventListener('click', () => activate(tab))
@@ -719,9 +798,161 @@
719
798
  toggle.addEventListener('click', () => {
720
799
  const pressed = toggle.getAttribute('aria-pressed') !== 'true'
721
800
 
801
+ const intent = new CustomEvent('ui:toggle-intent', {
802
+ bubbles: true,
803
+ cancelable: true,
804
+ detail: { pressed }
805
+ })
806
+
807
+ toggle.dispatchEvent(intent)
808
+
809
+ if (intent.defaultPrevented || toggle.hasAttribute('data-ui-controlled')) return
810
+
722
811
  toggle.setAttribute('aria-pressed', String(pressed))
812
+ })
813
+ }
814
+ }
815
+
816
+ const initThemeToggles = (scope: ParentNode): void => {
817
+ const root = document.documentElement
818
+ const selector = '[data-ui-theme-toggle]'
819
+ const transitionDurationMs = 580
820
+ const transitionFallbackDelayMs = 750
821
+ const transitionZIndex = 99999
822
+ const animatingControls = new WeakSet<HTMLButtonElement>()
823
+
824
+ const sync = (theme: string): void => {
825
+ for (const control of document.querySelectorAll<HTMLButtonElement>(selector)) {
826
+ const darkTheme = control.dataset.darkTheme ?? 'dark'
827
+ const isDark = theme === darkTheme
828
+
829
+ control.setAttribute('aria-pressed', String(isDark))
830
+
831
+ control.dataset.uiTheme = theme
832
+ }
833
+ }
834
+
835
+ const apply = (theme: string, control?: HTMLButtonElement): void => {
836
+ const previousTheme = root.dataset.theme
837
+ const darkTheme = control?.dataset.darkTheme ?? 'dark'
838
+
839
+ root.dataset.theme = theme
840
+
841
+ root.classList.toggle('dark', theme === darkTheme || theme.endsWith('-dark'))
842
+
843
+ sync(theme)
844
+
845
+ if (!control || previousTheme === theme) return
846
+
847
+ const storageKey = control.dataset.storageKey ?? 'lumen-theme'
848
+
849
+ try {
850
+ localStorage.setItem(storageKey, theme)
851
+ } catch {
852
+ // Storage can be unavailable in privacy modes; theme switching should still work.
853
+ }
854
+
855
+ control.dispatchEvent(new CustomEvent('ui:theme-change', {
856
+ bubbles: true,
857
+ detail: { previousTheme, storageKey, theme }
858
+ }))
859
+ }
860
+
861
+ const transition = (control: HTMLButtonElement, theme: string): void => {
862
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
863
+ const isTouchDevice = window.matchMedia('(hover: none) and (pointer: coarse)').matches
864
+
865
+ if (prefersReducedMotion || isTouchDevice) {
866
+ apply(theme, control)
867
+
868
+ return
869
+ }
870
+
871
+ animatingControls.add(control)
872
+
873
+ control.dataset.uiThemeAnimating = 'true'
874
+
875
+ const rect = control.getBoundingClientRect()
876
+ const originX = Math.round(rect.left + (rect.width / 2))
877
+ const originY = Math.round(rect.top + (rect.height / 2))
878
+
879
+ const maximumRadius = Math.hypot(
880
+ Math.max(originX, window.innerWidth - originX),
881
+ Math.max(originY, window.innerHeight - originY)
882
+ )
883
+
884
+ const overlay = document.createElement('div')
885
+ const backgroundColor = getComputedStyle(document.body).backgroundColor
886
+
887
+ overlay.dataset.uiThemeTransition = ''
888
+
889
+ overlay.setAttribute('aria-hidden', 'true')
890
+
891
+ Object.assign(overlay.style, {
892
+ position: 'fixed',
893
+ inset: '0',
894
+ zIndex: String(transitionZIndex),
895
+ pointerEvents: 'none',
896
+ backgroundColor,
897
+ clipPath: `circle(${maximumRadius}px at ${originX}px ${originY}px)`,
898
+ willChange: 'clip-path'
899
+ })
900
+
901
+ document.body.appendChild(overlay)
902
+
903
+ apply(theme, control)
904
+
905
+ requestAnimationFrame(() => {
906
+ requestAnimationFrame(() => {
907
+ overlay.style.transition = `clip-path ${transitionDurationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`
908
+
909
+ overlay.style.clipPath = `circle(0 at ${originX}px ${originY}px)`
910
+
911
+ const complete = (): void => {
912
+ overlay.remove()
723
913
 
724
- toggle.classList.toggle('ui-toggle--pressed', pressed)
914
+ animatingControls.delete(control)
915
+
916
+ delete control.dataset.uiThemeAnimating
917
+ }
918
+
919
+ overlay.addEventListener('transitionend', complete, { once: true })
920
+
921
+ window.setTimeout(complete, transitionFallbackDelayMs)
922
+ })
923
+ })
924
+ }
925
+
926
+ for (const control of scope.querySelectorAll<HTMLButtonElement>(selector)) {
927
+ if (control.dataset.uiBound === 'true') continue
928
+
929
+ control.dataset.uiBound = 'true'
930
+
931
+ const storageKey = control.dataset.storageKey ?? 'lumen-theme'
932
+ let storedTheme: string | null = null
933
+
934
+ try {
935
+ storedTheme = localStorage.getItem(storageKey)
936
+ } catch {
937
+ // Storage can be unavailable in privacy modes.
938
+ }
939
+
940
+ const currentTheme = storedTheme ?? root.dataset.theme
941
+
942
+ if (currentTheme) {
943
+ apply(currentTheme)
944
+ } else {
945
+ sync(control.dataset.lightTheme ?? 'light')
946
+ }
947
+
948
+ control.addEventListener('click', () => {
949
+ if (control.hasAttribute('data-ui-controlled') || animatingControls.has(control)) return
950
+
951
+ const lightTheme = control.dataset.lightTheme ?? 'light'
952
+ const darkTheme = control.dataset.darkTheme ?? 'dark'
953
+ const current = root.dataset.theme
954
+
955
+ transition(control, current === darkTheme ? lightTheme : darkTheme)
725
956
  })
726
957
  }
727
958
  }
@@ -1785,10 +2016,12 @@
1785
2016
  }
1786
2017
 
1787
2018
  if (dispatch) {
1788
- root.dispatchEvent(new CustomEvent('ui:datatable-selection-change', {
1789
- bubbles: true,
1790
- detail: { values: selectedValues }
1791
- }))
2019
+ const detail = { values: selectedValues }
2020
+
2021
+ root.dispatchEvent(new CustomEvent('ui:data-table-selection-change', { bubbles: true, detail }))
2022
+
2023
+ // Deprecated compatibility event. Remove in Lumen 1.0.
2024
+ root.dispatchEvent(new CustomEvent('ui:datatable-selection-change', { bubbles: true, detail }))
1792
2025
  }
1793
2026
  }
1794
2027
 
@@ -4345,140 +4578,18 @@
4345
4578
  }
4346
4579
  }
4347
4580
 
4348
- const initBackToTopButtons = (scope: ParentNode): void => {
4349
- for (const button of scope.querySelectorAll<HTMLButtonElement>('[data-ui-back-to-top]')) {
4350
- if (button.dataset.uiBound === 'true') continue
4581
+ const initOptionalControllers = (scope: ParentNode): void => {
4582
+ if (!scope.querySelector(
4583
+ '[data-ui-back-to-top], [data-ui-scroll-reveal], [data-ui-reveal-group], [data-ui-animated-number]'
4584
+ )) return
4351
4585
 
4352
- button.dataset.uiBound = 'true'
4353
-
4354
- button.addEventListener('click', () => {
4355
- window.scrollTo({
4356
- behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
4357
- top: 0
4586
+ import('./controllers/motion.js')
4587
+ .then(({ initMotionControllers }) => initMotionControllers(scope))
4588
+ .catch(error => {
4589
+ window.setTimeout(() => {
4590
+ throw error
4358
4591
  })
4359
4592
  })
4360
- }
4361
- }
4362
-
4363
- const initScrollReveals = (scope: ParentNode): void => {
4364
- for (const root of scope.querySelectorAll<HTMLElement>(
4365
- '[data-ui-scroll-reveal], [data-ui-reveal-group]'
4366
- )) {
4367
- if (root.dataset.uiScrollRevealBound === 'true') continue
4368
-
4369
- root.dataset.uiScrollRevealBound = 'true'
4370
-
4371
- if (root.matches('[data-ui-reveal-group]')) {
4372
- for (const [index, child] of [...root.children].entries()) {
4373
- if (child instanceof HTMLElement) {
4374
- child.style.setProperty('--ui-reveal-index', String(index))
4375
- }
4376
- }
4377
- }
4378
-
4379
- if (
4380
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
4381
- || typeof IntersectionObserver === 'undefined'
4382
- ) {
4383
- root.classList.add('is-revealed')
4384
-
4385
- continue
4386
- }
4387
-
4388
- const once = root.dataset.uiRevealOnce !== 'false'
4389
-
4390
- const threshold = Math.min(
4391
- 1,
4392
- Math.max(0, Number(root.dataset.uiRevealThreshold) || 0)
4393
- )
4394
-
4395
- const observer = new IntersectionObserver((entries) => {
4396
- const entry = entries[0]
4397
-
4398
- if (!entry) return
4399
-
4400
- root.classList.toggle('is-revealed', entry.isIntersecting)
4401
-
4402
- if (entry.isIntersecting && once) observer.disconnect()
4403
- }, { threshold })
4404
-
4405
- observer.observe(root)
4406
- }
4407
- }
4408
-
4409
- const initAnimatedNumbers = (scope: ParentNode): void => {
4410
- for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-animated-number]')) {
4411
- if (root.dataset.uiBound === 'true') continue
4412
-
4413
- const output = root.querySelector<HTMLElement>('[data-ui-animated-number-output]')
4414
- const from = Number(root.dataset.uiAnimatedNumberFrom)
4415
- const value = Number(root.dataset.uiAnimatedNumberValue)
4416
-
4417
- if (!output || !Number.isFinite(from) || !Number.isFinite(value)) continue
4418
-
4419
- root.dataset.uiBound = 'true'
4420
-
4421
- const decimals = Math.max(
4422
- 0,
4423
- Math.min(20, Number(root.dataset.uiAnimatedNumberDecimals) || 0)
4424
- )
4425
-
4426
- const prefix = root.dataset.uiAnimatedNumberPrefix ?? ''
4427
- const suffix = root.dataset.uiAnimatedNumberSuffix ?? ''
4428
-
4429
- const formatter = new Intl.NumberFormat(root.dataset.uiAnimatedNumberLocale || undefined, {
4430
- maximumFractionDigits: decimals,
4431
- minimumFractionDigits: decimals
4432
- })
4433
-
4434
- const format = (current: number): string =>
4435
- `${prefix}${formatter.format(current)}${suffix}`
4436
-
4437
- if (
4438
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
4439
- || typeof IntersectionObserver === 'undefined'
4440
- ) {
4441
- output.textContent = format(value)
4442
-
4443
- continue
4444
- }
4445
-
4446
- output.textContent = format(from)
4447
-
4448
- const observer = new IntersectionObserver((entries) => {
4449
- if (!entries.some(entry => entry.isIntersecting)) return
4450
-
4451
- observer.disconnect()
4452
-
4453
- const durationValue = getComputedStyle(root)
4454
- .getPropertyValue('--ui-motion-duration')
4455
- .trim()
4456
-
4457
- const parsedDuration = Number.parseFloat(durationValue)
4458
- let durationMs = 300
4459
-
4460
- if (Number.isFinite(parsedDuration)) {
4461
- durationMs = durationValue.endsWith('ms')
4462
- ? parsedDuration
4463
- : parsedDuration * 1000
4464
- }
4465
-
4466
- const startedAt = performance.now()
4467
-
4468
- const update = (now: number): void => {
4469
- const progress = Math.min(1, (now - startedAt) / Math.max(1, durationMs))
4470
- const eased = 1 - Math.pow(1 - progress, 3)
4471
-
4472
- output.textContent = format(from + ((value - from) * eased))
4473
-
4474
- if (progress < 1) requestAnimationFrame(update)
4475
- }
4476
-
4477
- requestAnimationFrame(update)
4478
- }, { threshold: 0.15 })
4479
-
4480
- observer.observe(root)
4481
- }
4482
4593
  }
4483
4594
 
4484
4595
  const initUiPrimitives = (scope: ParentNode = document): void => {
@@ -4496,6 +4607,8 @@
4496
4607
 
4497
4608
  initToggles(scope)
4498
4609
 
4610
+ initThemeToggles(scope)
4611
+
4499
4612
  initCommand(scope)
4500
4613
 
4501
4614
  initComboboxes(scope)
@@ -4554,11 +4667,7 @@
4554
4667
 
4555
4668
  initTreeSelects(scope)
4556
4669
 
4557
- initBackToTopButtons(scope)
4558
-
4559
- initScrollReveals(scope)
4560
-
4561
- initAnimatedNumbers(scope)
4670
+ initOptionalControllers(scope)
4562
4671
 
4563
4672
  initToasts()
4564
4673
  }
@@ -0,0 +1,145 @@
1
+ /* eslint-disable complexity, unicorn/consistent-function-scoping -- Optional motion controllers keep their state and formatting behavior local to the matching primitives. */
2
+
3
+ const initBackToTopButtons = (scope: ParentNode): void => {
4
+ for (const button of scope.querySelectorAll<HTMLButtonElement>('[data-ui-back-to-top]')) {
5
+ if (button.dataset.uiBound === 'true') continue
6
+
7
+ button.dataset.uiBound = 'true'
8
+
9
+ button.addEventListener('click', () => {
10
+ window.scrollTo({
11
+ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
12
+ top: 0
13
+ })
14
+ })
15
+ }
16
+ }
17
+
18
+ const initScrollReveals = (scope: ParentNode): void => {
19
+ for (const root of scope.querySelectorAll<HTMLElement>(
20
+ '[data-ui-scroll-reveal], [data-ui-reveal-group]'
21
+ )) {
22
+ if (root.dataset.uiScrollRevealBound === 'true') continue
23
+
24
+ root.dataset.uiScrollRevealBound = 'true'
25
+
26
+ if (root.matches('[data-ui-reveal-group]')) {
27
+ for (const [index, child] of [...root.children].entries()) {
28
+ if (child instanceof HTMLElement) {
29
+ child.style.setProperty('--ui-reveal-index', String(index))
30
+ }
31
+ }
32
+ }
33
+
34
+ if (
35
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
36
+ || typeof IntersectionObserver === 'undefined'
37
+ ) {
38
+ root.classList.add('is-revealed')
39
+
40
+ continue
41
+ }
42
+
43
+ const once = root.dataset.uiRevealOnce !== 'false'
44
+
45
+ const threshold = Math.min(
46
+ 1,
47
+ Math.max(0, Number(root.dataset.uiRevealThreshold) || 0)
48
+ )
49
+
50
+ const observer = new IntersectionObserver((entries) => {
51
+ const entry = entries[0]
52
+
53
+ if (!entry) return
54
+
55
+ root.classList.toggle('is-revealed', entry.isIntersecting)
56
+
57
+ if (entry.isIntersecting && once) observer.disconnect()
58
+ }, { threshold })
59
+
60
+ observer.observe(root)
61
+ }
62
+ }
63
+
64
+ const initAnimatedNumbers = (scope: ParentNode): void => {
65
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-animated-number]')) {
66
+ if (root.dataset.uiBound === 'true') continue
67
+
68
+ const output = root.querySelector<HTMLElement>('[data-ui-animated-number-output]')
69
+ const from = Number(root.dataset.uiAnimatedNumberFrom)
70
+ const value = Number(root.dataset.uiAnimatedNumberValue)
71
+
72
+ if (!output || !Number.isFinite(from) || !Number.isFinite(value)) continue
73
+
74
+ root.dataset.uiBound = 'true'
75
+
76
+ const decimals = Math.max(
77
+ 0,
78
+ Math.min(20, Number(root.dataset.uiAnimatedNumberDecimals) || 0)
79
+ )
80
+
81
+ const prefix = root.dataset.uiAnimatedNumberPrefix ?? ''
82
+ const suffix = root.dataset.uiAnimatedNumberSuffix ?? ''
83
+
84
+ const formatter = new Intl.NumberFormat(root.dataset.uiAnimatedNumberLocale || undefined, {
85
+ maximumFractionDigits: decimals,
86
+ minimumFractionDigits: decimals
87
+ })
88
+
89
+ const format = (current: number): string =>
90
+ `${prefix}${formatter.format(current)}${suffix}`
91
+
92
+ if (
93
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
94
+ || typeof IntersectionObserver === 'undefined'
95
+ ) {
96
+ output.textContent = format(value)
97
+
98
+ continue
99
+ }
100
+
101
+ output.textContent = format(from)
102
+
103
+ const observer = new IntersectionObserver((entries) => {
104
+ if (!entries.some(entry => entry.isIntersecting)) return
105
+
106
+ observer.disconnect()
107
+
108
+ const durationValue = getComputedStyle(root)
109
+ .getPropertyValue('--ui-motion-duration')
110
+ .trim()
111
+
112
+ const parsedDuration = Number.parseFloat(durationValue)
113
+ let durationMs = 300
114
+
115
+ if (Number.isFinite(parsedDuration)) {
116
+ durationMs = durationValue.endsWith('ms')
117
+ ? parsedDuration
118
+ : parsedDuration * 1000
119
+ }
120
+
121
+ const startedAt = performance.now()
122
+
123
+ const update = (now: number): void => {
124
+ const progress = Math.min(1, (now - startedAt) / Math.max(1, durationMs))
125
+ const eased = 1 - Math.pow(1 - progress, 3)
126
+
127
+ output.textContent = format(from + ((value - from) * eased))
128
+
129
+ if (progress < 1) requestAnimationFrame(update)
130
+ }
131
+
132
+ requestAnimationFrame(update)
133
+ }, { threshold: 0.15 })
134
+
135
+ observer.observe(root)
136
+ }
137
+ }
138
+
139
+ export const initMotionControllers = (scope: ParentNode): void => {
140
+ initBackToTopButtons(scope)
141
+
142
+ initScrollReveals(scope)
143
+
144
+ initAnimatedNumbers(scope)
145
+ }
@@ -0,0 +1 @@
1
+ @import "@santi020k/lumen/layers.css";