@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
@@ -0,0 +1,19 @@
1
+ import type { LumenChartDatumActivationDetail, LumenChartLabels } from '@santi020k/lumen-core'
2
+
3
+ export interface ChartDatumAction {
4
+ detail: LumenChartDatumActivationDetail
5
+ label: string
6
+ serialized: string
7
+ }
8
+
9
+ export const createChartDatumAction = (
10
+ detail: LumenChartDatumActivationDetail | null,
11
+ context: string,
12
+ labels: Readonly<LumenChartLabels>
13
+ ): ChartDatumAction | null => detail ?
14
+ {
15
+ detail,
16
+ label: labels.formatDatumAction(context),
17
+ serialized: JSON.stringify(detail)
18
+ } :
19
+ null
@@ -0,0 +1,12 @@
1
+ import { createLumenAmountFieldController, type LumenAmountFieldController } from '@santi020k/lumen-core'
2
+
3
+ const enhanced = new WeakMap<HTMLElement, LumenAmountFieldController>()
4
+
5
+ export const initAmountFields = (scope: ParentNode): void => {
6
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-amount-field]')) {
7
+ const controller = enhanced.get(root)
8
+
9
+ if (controller) controller.refresh()
10
+ else enhanced.set(root, createLumenAmountFieldController(root))
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ import { createLumenAttachmentPreviewController } from '@santi020k/lumen-core'
2
+
3
+ const enhanced = new WeakSet<HTMLElement>()
4
+
5
+ export const initAttachmentPreviewControllers = (scope: ParentNode): void => {
6
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-attachment-preview]')) {
7
+ if (enhanced.has(root)) continue
8
+
9
+ createLumenAttachmentPreviewController(root)
10
+
11
+ enhanced.add(root)
12
+ }
13
+ }
@@ -0,0 +1,33 @@
1
+ import { createLumenChartActivationController } from '@santi020k/lumen-core'
2
+
3
+ const controllers = new Map<HTMLElement, ReturnType<typeof createLumenChartActivationController>>()
4
+
5
+ export const initChartActivationControllers = (scope: ParentNode): void => {
6
+ for (const [root, controller] of controllers) {
7
+ if (root.isConnected) continue
8
+
9
+ controller.destroy()
10
+
11
+ root.removeAttribute('data-ui-chart-activation-bound')
12
+
13
+ controllers.delete(root)
14
+ }
15
+
16
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-chart-activation]:not([data-ui-chart-adapter="react"]):not([data-ui-chart-adapter="elements"])')) {
17
+ if (!controllers.has(root)) {
18
+ controllers.set(root, createLumenChartActivationController(root))
19
+
20
+ root.setAttribute('data-ui-chart-activation-bound', 'true')
21
+ }
22
+ }
23
+ }
24
+
25
+ document.addEventListener('astro:before-swap', () => {
26
+ for (const [root, controller] of controllers) {
27
+ controller.destroy()
28
+
29
+ root.removeAttribute('data-ui-chart-activation-bound')
30
+ }
31
+
32
+ controllers.clear()
33
+ })
@@ -0,0 +1,33 @@
1
+ import { createLumenChartInteractionController } from '@santi020k/lumen-core'
2
+
3
+ interface BoundChart {
4
+ controller: ReturnType<typeof createLumenChartInteractionController>
5
+ document: Document
6
+ }
7
+
8
+ const controllers = new Map<HTMLElement, BoundChart>()
9
+
10
+ export const initChartControllers = (scope: ParentNode): void => {
11
+ for (const [root, binding] of controllers) {
12
+ if (!root.isConnected || root.ownerDocument !== binding.document) {
13
+ binding.controller.destroy()
14
+
15
+ controllers.delete(root)
16
+ }
17
+ }
18
+
19
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-chart-interactive]')) {
20
+ if (!controllers.has(root)) {
21
+ controllers.set(root, {
22
+ controller: createLumenChartInteractionController(root),
23
+ document: root.ownerDocument
24
+ })
25
+ }
26
+ }
27
+ }
28
+
29
+ document.addEventListener('astro:before-swap', () => {
30
+ for (const { controller } of controllers.values()) controller.destroy()
31
+
32
+ controllers.clear()
33
+ })
@@ -0,0 +1,39 @@
1
+ import { createLumenComboboxController, type LumenComboboxController } from '@santi020k/lumen-core'
2
+
3
+ interface BoundCombobox {
4
+ controller: LumenComboboxController
5
+ document: Document
6
+ eventRoot: Node
7
+ }
8
+
9
+ const controllers = new Map<HTMLElement, BoundCombobox>()
10
+
11
+ export const closeCombobox = (root: HTMLElement): void => {
12
+ controllers.get(root)?.controller.close()
13
+ }
14
+
15
+ export const clearComboboxes = (): void => {
16
+ for (const { controller } of controllers.values()) controller.destroy()
17
+
18
+ controllers.clear()
19
+ }
20
+
21
+ export const initComboboxes = (scope: ParentNode): void => {
22
+ for (const [root, binding] of controllers) {
23
+ if (!root.isConnected || root.ownerDocument !== binding.document || root.getRootNode() !== binding.eventRoot) {
24
+ binding.controller.destroy()
25
+
26
+ controllers.delete(root)
27
+ }
28
+ }
29
+
30
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-combobox]')) {
31
+ if (!controllers.has(root)) {
32
+ controllers.set(root, {
33
+ controller: createLumenComboboxController(root),
34
+ document: root.ownerDocument,
35
+ eventRoot: root.getRootNode()
36
+ })
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,20 @@
1
+ const roots = new WeakSet<Node>()
2
+ const isRoot = (node: Node): node is ParentNode => 'querySelectorAll' in node
3
+
4
+ export const bindComparisonReset = (node: Node, syncs: WeakMap<HTMLInputElement, () => number>): void => {
5
+ const scope = node.getRootNode()
6
+
7
+ if (!isRoot(scope) || roots.has(scope)) return
8
+
9
+ roots.add(scope)
10
+
11
+ scope.addEventListener('reset', event => {
12
+ setTimeout(() => {
13
+ if (event.defaultPrevented) return
14
+
15
+ for (const input of scope.querySelectorAll<HTMLInputElement>('[data-ui-image-comparison-input]')) {
16
+ if (input.isConnected && input.form === event.target) syncs.get(input)?.()
17
+ }
18
+ })
19
+ }, true)
20
+ }
@@ -0,0 +1,142 @@
1
+ /* cspell:ignore Datatable datatable */
2
+ const nextSortDirection = (current: string | null): 'ascending' | 'descending' | 'none' => {
3
+ if (current === 'ascending') return 'descending'
4
+
5
+ if (current === 'descending') return 'none'
6
+
7
+ return 'ascending'
8
+ }
9
+
10
+ const getDataTableSortValue = (
11
+ cell: HTMLTableCellElement | undefined
12
+ ): string => cell?.dataset.sortValue ?? cell?.textContent.trim() ?? ''
13
+
14
+ const compareDataTableValues = (value: string, other: string, sortType: string | undefined): number => {
15
+ const numericValue = Number(value.replaceAll(',', ''))
16
+ const numericOther = Number(other.replaceAll(',', ''))
17
+
18
+ const useNumeric = sortType === 'number' || (
19
+ sortType !== 'string' &&
20
+ value.trim() !== '' &&
21
+ other.trim() !== '' &&
22
+ Number.isFinite(numericValue) &&
23
+ Number.isFinite(numericOther)
24
+ )
25
+
26
+ if (useNumeric) {
27
+ return numericValue - numericOther
28
+ }
29
+
30
+ return value.localeCompare(other, undefined, { numeric: true, sensitivity: 'base' })
31
+ }
32
+
33
+ const sortDataTableRows = (
34
+ table: HTMLTableElement,
35
+ header: HTMLTableCellElement,
36
+ columnIndex: number,
37
+ direction: 'ascending' | 'descending' | 'none'
38
+ ): void => {
39
+ const body = table.tBodies[0]
40
+
41
+ if (!body) return
42
+
43
+ const rows = [...body.rows]
44
+ const sortType = header.dataset.uiDatatableSortType
45
+
46
+ rows.sort((row, other) => {
47
+ if (direction === 'none') {
48
+ return Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
49
+ }
50
+
51
+ const result = compareDataTableValues(
52
+ getDataTableSortValue(row.cells[columnIndex]), getDataTableSortValue(other.cells[columnIndex]), sortType
53
+ )
54
+
55
+ return (direction === 'ascending' ? result : -result) ||
56
+ Number(row.dataset.uiDatatableIndex ?? '0') - Number(other.dataset.uiDatatableIndex ?? '0')
57
+ })
58
+
59
+ rows.forEach(row => {
60
+ body.append(row)
61
+ })
62
+ }
63
+
64
+ const updateDataTableSort = (
65
+ root: HTMLElement,
66
+ table: HTMLTableElement,
67
+ header: HTMLTableCellElement,
68
+ direction: 'ascending' | 'descending' | 'none'
69
+ ): void => {
70
+ for (const item of table.querySelectorAll<HTMLTableCellElement>('thead th[aria-sort]')) {
71
+ item.setAttribute('aria-sort', item === header ? direction : 'none')
72
+
73
+ item.dataset.sort = item === header ? direction : 'none'
74
+ }
75
+
76
+ root.dataset.uiDatatableSortDirection = direction
77
+
78
+ if (direction === 'none') {
79
+ delete root.dataset.uiDatatableSortColumn
80
+ } else {
81
+ root.dataset.uiDatatableSortColumn = header.dataset.uiDatatableColumn ?? ''
82
+ }
83
+ }
84
+
85
+ export const initDataTableSorting = (root: HTMLElement, table: HTMLTableElement): void => {
86
+ const headers = [...table.querySelectorAll<HTMLTableCellElement>('thead th')].filter(
87
+ header => header.dataset.uiDatatableSortable === 'true' ||
88
+ header.dataset.sortable === 'true' ||
89
+ header.hasAttribute('data-sortable')
90
+ )
91
+
92
+ for (const header of headers) {
93
+ if (header.dataset.uiDatatableSortBound === 'true') continue
94
+
95
+ const row = header.parentElement
96
+ const columnIndex = row ? [...row.children].indexOf(header) : -1
97
+
98
+ if (columnIndex < 0) continue
99
+
100
+ header.dataset.uiDatatableSortBound = 'true'
101
+
102
+ header.dataset.uiDatatableColumn = String(columnIndex)
103
+
104
+ header.setAttribute('aria-sort', header.getAttribute('aria-sort') ?? 'none')
105
+
106
+ const button = document.createElement('button')
107
+
108
+ button.type = 'button'
109
+
110
+ button.className = 'ui-data-table__sort'
111
+
112
+ button.dataset.uiDatatableSort = ''
113
+
114
+ while (header.firstChild) {
115
+ button.append(header.firstChild)
116
+ }
117
+
118
+ header.append(button)
119
+
120
+ button.addEventListener('click', () => {
121
+ const currentDirection = header.getAttribute('aria-sort')
122
+ const nextDirection = nextSortDirection(currentDirection)
123
+
124
+ updateDataTableSort(root, table, header, nextDirection)
125
+
126
+ if (root.dataset.uiDatatableSortMode !== 'manual') sortDataTableRows(table, header, columnIndex, nextDirection)
127
+
128
+ root.dispatchEvent(new CustomEvent('ui:data-table-sort-change', {
129
+ bubbles: true,
130
+ detail: { key: header.dataset.uiDatatableSortKey ?? String(columnIndex), columnIndex, direction: nextDirection }
131
+ }))
132
+ })
133
+ }
134
+
135
+ const body = table.tBodies[0]
136
+
137
+ if (!body) return
138
+
139
+ ;[...body.rows].forEach((row, index) => {
140
+ row.dataset.uiDatatableIndex = row.dataset.uiDatatableIndex ?? String(index)
141
+ })
142
+ }
@@ -1,21 +1,13 @@
1
- const focusableSelector = [
2
- 'a[href]',
3
- 'button:not([disabled])',
4
- 'input:not([disabled])',
5
- 'select:not([disabled])',
6
- 'textarea:not([disabled])',
7
- '[tabindex]:not([tabindex="-1"])'
8
- ].join(',')
9
-
10
- const isElementVisible = (element: HTMLElement): boolean => (
11
- typeof element.checkVisibility === 'function' ?
12
- element.checkVisibility() :
13
- element.offsetParent !== null
14
- )
15
-
16
- const getFocusable = (root: ParentNode): HTMLElement[] => [
17
- ...root.querySelectorAll<HTMLElement>(focusableSelector)
18
- ].filter(element => !element.hasAttribute('hidden') && isElementVisible(element))
1
+ const dialogTriggers = new WeakMap<HTMLDialogElement, HTMLElement>()
2
+
3
+ const isBackdropPoint = (dialog: HTMLDialogElement, event: MouseEvent): boolean => {
4
+ if (event.target !== dialog) return false
5
+
6
+ const rect = dialog.getBoundingClientRect()
7
+
8
+ return event.clientX < rect.left || event.clientX > rect.right ||
9
+ event.clientY < rect.top || event.clientY > rect.bottom
10
+ }
19
11
 
20
12
  export const initDialogControllers = (scope: ParentNode): void => {
21
13
  const dialogSelector =
@@ -32,17 +24,27 @@ export const initDialogControllers = (scope: ParentNode): void => {
32
24
  'role', dialog.hasAttribute('data-ui-alert-dialog') ? 'alertdialog' : 'dialog'
33
25
  )
34
26
 
27
+ let pressStartedOutside = false
28
+
29
+ dialog.addEventListener('pointerdown', event => {
30
+ pressStartedOutside = isBackdropPoint(dialog, event)
31
+ })
32
+
35
33
  dialog.addEventListener('click', event => {
36
- if (event.target === dialog && !dialog.hasAttribute('data-ui-alert-dialog')) {
34
+ const dismiss = pressStartedOutside && event.detail > 0 &&
35
+ !event.defaultPrevented && isBackdropPoint(dialog, event)
36
+
37
+ pressStartedOutside = false
38
+
39
+ if (dismiss && !dialog.hasAttribute('data-ui-alert-dialog')) {
37
40
  dialog.close()
38
41
  }
39
42
  })
40
43
 
41
44
  dialog.addEventListener('close', () => {
42
- const triggerId = dialog.dataset.uiLastTrigger
43
- const trigger = triggerId ? document.getElementById(triggerId) : null
45
+ const trigger = dialogTriggers.get(dialog)
44
46
 
45
- if (trigger instanceof HTMLElement) {
47
+ if (trigger?.isConnected) {
46
48
  trigger.focus({ preventScroll: true })
47
49
  }
48
50
  })
@@ -62,7 +64,9 @@ export const initDialogControllers = (scope: ParentNode): void => {
62
64
 
63
65
  trigger.setAttribute('aria-haspopup', 'dialog')
64
66
 
65
- trigger.addEventListener('click', () => {
67
+ trigger.addEventListener('click', event => {
68
+ if (event.defaultPrevented || trigger.matches(':disabled, [aria-disabled="true"]')) return
69
+
66
70
  const targetId =
67
71
  trigger.dataset.uiDialogTrigger ??
68
72
  trigger.dataset.uiAlertDialogTrigger ??
@@ -73,17 +77,11 @@ export const initDialogControllers = (scope: ParentNode): void => {
73
77
 
74
78
  const dialog = document.getElementById(targetId)
75
79
 
76
- if (!(dialog instanceof HTMLDialogElement)) return
77
-
78
- if (!trigger.id) {
79
- trigger.id = `ui-trigger-${crypto.randomUUID()}`
80
- }
81
-
82
- dialog.dataset.uiLastTrigger = trigger.id
80
+ if (!(dialog instanceof HTMLDialogElement) || dialog.open) return
83
81
 
84
82
  dialog.showModal()
85
83
 
86
- getFocusable(dialog)[0]?.focus({ preventScroll: true })
84
+ if (dialog.hasAttribute('open')) dialogTriggers.set(dialog, trigger)
87
85
  })
88
86
  }
89
87
 
@@ -99,7 +97,9 @@ export const initDialogControllers = (scope: ParentNode): void => {
99
97
 
100
98
  closeButton.dataset.uiBound = 'true'
101
99
 
102
- closeButton.addEventListener('click', () => {
100
+ closeButton.addEventListener('click', event => {
101
+ if (event.defaultPrevented || closeButton.matches(':disabled, [aria-disabled="true"]')) return
102
+
103
103
  closeButton.closest<HTMLDialogElement>('dialog')?.close()
104
104
  })
105
105
  }
@@ -1,15 +1,63 @@
1
+ const decodeFragment = (fragment: string): string => {
2
+ try {
3
+ return decodeURIComponent(fragment)
4
+ } catch {
5
+ // A literal percent sign can still identify a section; isolate malformed escapes.
6
+ return fragment
7
+ }
8
+ }
9
+
10
+ interface AnchorDispatcher {
11
+ callbacks: WeakMap<HTMLElement, () => void>
12
+ frame: number
13
+ }
14
+
15
+ const anchorDispatchers = new WeakMap<Document, AnchorDispatcher>()
16
+
17
+ const getAnchorDispatcher = (ownerDocument: Document): AnchorDispatcher => {
18
+ const existing = anchorDispatchers.get(ownerDocument)
19
+
20
+ if (existing) return existing
21
+
22
+ const dispatcher: AnchorDispatcher = { callbacks: new WeakMap(), frame: 0 }
23
+
24
+ const runUpdates = (): void => {
25
+ dispatcher.frame = 0
26
+
27
+ for (const root of ownerDocument.querySelectorAll<HTMLElement>('[data-ui-anchor]')) {
28
+ dispatcher.callbacks.get(root)?.()
29
+ }
30
+ }
31
+
32
+ const requestUpdates = (): void => {
33
+ if (dispatcher.frame) return
34
+
35
+ dispatcher.frame = requestAnimationFrame(runUpdates)
36
+ }
37
+
38
+ ownerDocument.defaultView?.addEventListener('resize', requestUpdates, { passive: true })
39
+
40
+ ownerDocument.defaultView?.addEventListener('scroll', requestUpdates, { passive: true })
41
+
42
+ anchorDispatchers.set(ownerDocument, dispatcher)
43
+
44
+ return dispatcher
45
+ }
46
+
1
47
  const initAnchors = (scope: ParentNode): void => {
2
48
  for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-anchor]')) {
3
49
  if (root.dataset.uiBound === 'true') continue
4
50
 
5
51
  root.dataset.uiBound = 'true'
6
52
 
53
+ const ownerDocument = root.ownerDocument
7
54
  const links = [...root.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')]
8
55
  const targets: { link: HTMLAnchorElement, target: HTMLElement }[] = []
9
56
 
10
57
  for (const link of links) {
11
- const id = decodeURIComponent(link.getAttribute('href')?.slice(1) ?? '')
12
- const target = id ? document.getElementById(id) : null
58
+ const fragment = link.getAttribute('href')?.slice(1) ?? ''
59
+ const id = decodeFragment(fragment)
60
+ const target = id ? ownerDocument.getElementById(id) : null
13
61
 
14
62
  if (target) {
15
63
  targets.push({ link, target })
@@ -33,11 +81,9 @@ const initAnchors = (scope: ParentNode): void => {
33
81
  }
34
82
 
35
83
  const update = (): void => {
36
- const scrollingElement = document.scrollingElement ?? document.documentElement
37
-
38
- const atEnd = scrollingElement.scrollTop + scrollingElement.clientHeight >=
39
- scrollingElement.scrollHeight - 1
40
-
84
+ const scrollingElement = ownerDocument.scrollingElement ?? ownerDocument.documentElement
85
+ const maximum = scrollingElement.scrollHeight - scrollingElement.clientHeight
86
+ const atEnd = maximum > 0 && scrollingElement.scrollTop >= maximum - 1
41
87
  const offset = Number(root.dataset.uiAnchorOffset) || 0
42
88
  let active = targets[0]?.link
43
89
 
@@ -54,27 +100,13 @@ const initAnchors = (scope: ParentNode): void => {
54
100
  if (active) setActive(active)
55
101
  }
56
102
 
57
- let frame = 0
58
-
59
- const requestUpdate = (): void => {
60
- if (frame) return
61
-
62
- frame = requestAnimationFrame(() => {
63
- frame = 0
64
-
65
- update()
66
- })
67
- }
68
-
69
103
  for (const { link } of targets) {
70
104
  link.addEventListener('click', () => {
71
105
  setActive(link)
72
106
  })
73
107
  }
74
108
 
75
- window.addEventListener('resize', requestUpdate, { passive: true })
76
-
77
- window.addEventListener('scroll', requestUpdate, { passive: true })
109
+ getAnchorDispatcher(ownerDocument).callbacks.set(root, update)
78
110
 
79
111
  update()
80
112
  }
@@ -0,0 +1,89 @@
1
+ const renderers = new WeakMap<HTMLInputElement, () => void>()
2
+ const resetRoots = new WeakSet<Node>()
3
+
4
+ const isQueryRoot = (root: Node): root is Document | DocumentFragment | Element => (
5
+ 'querySelectorAll' in root && typeof root.querySelectorAll === 'function'
6
+ )
7
+
8
+ const bindResets = (input: HTMLInputElement): void => {
9
+ const scope = input.getRootNode()
10
+
11
+ if (!isQueryRoot(scope) || resetRoots.has(scope)) return
12
+
13
+ resetRoots.add(scope)
14
+
15
+ scope.addEventListener('reset', event => {
16
+ // Wait for the native reset default action.
17
+ setTimeout(() => {
18
+ if (event.defaultPrevented) return
19
+
20
+ for (const current of scope.querySelectorAll<HTMLInputElement>('[data-ui-file-upload-input]')) {
21
+ if (current.isConnected && current.form === event.target) renderers.get(current)?.()
22
+ }
23
+ })
24
+ }, { capture: true })
25
+ }
26
+
27
+ export const initFileUploadControllers = (scope: ParentNode): void => {
28
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-file-upload]')) {
29
+ const input = root.querySelector<HTMLInputElement>('[data-ui-file-upload-input]')
30
+
31
+ if (!input) continue
32
+
33
+ bindResets(input)
34
+
35
+ if (root.dataset.uiBound === 'true') continue
36
+
37
+ root.dataset.uiBound = 'true'
38
+
39
+ const files = root.querySelector<HTMLElement>('[data-ui-file-upload-files]')
40
+
41
+ const renderFiles = (): void => {
42
+ const selectedFiles = input.files ? [...input.files] : []
43
+
44
+ root.dataset.state = selectedFiles.length > 0 ? 'selected' : 'idle'
45
+
46
+ if (!files) return
47
+
48
+ if (selectedFiles.length === 1) {
49
+ files.textContent = selectedFiles[0]?.name ?? ''
50
+ } else {
51
+ files.textContent = selectedFiles.length > 1 ?
52
+ (root.dataset.uiSelectedFilesLabel ?? '{count} files selected').replaceAll('{count}', String(selectedFiles.length)) :
53
+ ''
54
+ }
55
+ }
56
+
57
+ input.addEventListener('change', renderFiles)
58
+
59
+ renderers.set(input, renderFiles)
60
+
61
+ root.addEventListener('dragover', event => {
62
+ if (input.matches(':disabled')) return
63
+
64
+ event.preventDefault()
65
+
66
+ root.dataset.state = 'drag-over'
67
+ })
68
+
69
+ root.addEventListener('dragleave', event => {
70
+ if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return
71
+
72
+ root.dataset.state = input.files?.length ? 'selected' : 'idle'
73
+ })
74
+
75
+ root.addEventListener('drop', event => {
76
+ if (input.matches(':disabled')) return
77
+
78
+ event.preventDefault()
79
+
80
+ if (event.dataTransfer) {
81
+ input.files = event.dataTransfer.files
82
+
83
+ input.dispatchEvent(new Event('change', { bubbles: true }))
84
+ }
85
+ })
86
+
87
+ renderFiles()
88
+ }
89
+ }
@@ -0,0 +1,53 @@
1
+ import { formatLumenImageComparisonValue, normalizeLumenImageComparisonMode, normalizeLumenImageComparisonValue, syncLumenImageComparisonMode } from '@santi020k/lumen-core'
2
+
3
+ import { bindComparisonReset } from './comparison-reset.js'
4
+
5
+ const syncs = new WeakMap<HTMLInputElement, () => number>()
6
+
7
+ export const initImageComparisonControllers = (scope: ParentNode): void => {
8
+ for (const root of scope.querySelectorAll<HTMLElement>('[data-ui-image-comparison]')) {
9
+ const input = root.querySelector<HTMLInputElement>('[data-ui-image-comparison-input]')
10
+ const frame = root.querySelector<HTMLElement>('.ui-image-comparison__frame')
11
+
12
+ if (!input || !frame) continue
13
+
14
+ bindComparisonReset(input, syncs)
15
+
16
+ if (syncs.has(input)) continue
17
+
18
+ const update = () => {
19
+ syncLumenImageComparisonMode(root, normalizeLumenImageComparisonMode(root.dataset.mode))
20
+
21
+ input.disabled = root.dataset.disabled === 'true' || root.dataset.mode !== 'reveal'
22
+
23
+ const value = normalizeLumenImageComparisonValue(input.valueAsNumber)
24
+
25
+ input.value = `${value}`
26
+
27
+ input.ariaValueText = formatLumenImageComparisonValue(value, root.dataset.afterLabel ?? 'After', root.dataset.locale || root.closest('[lang]')?.getAttribute('lang') || undefined)
28
+
29
+ frame.style.setProperty('--ui-image-comparison-position', `${value}%`)
30
+
31
+ return value
32
+ }
33
+
34
+ input.addEventListener('input', () => {
35
+ if (input.disabled) return
36
+
37
+ root.dispatchEvent(new CustomEvent('ui:image-comparison-change', {
38
+ bubbles: true,
39
+ detail: { value: update() }
40
+ }))
41
+ })
42
+
43
+ input.addEventListener('change', update)
44
+
45
+ const observer = new MutationObserver(update)
46
+
47
+ observer.observe(root, { attributes: true, attributeFilter: ['data-mode', 'data-disabled'] })
48
+
49
+ syncs.set(input, update)
50
+
51
+ update()
52
+ }
53
+ }