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