@santi020k/lumen-react 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/dist/hooks.js ADDED
@@ -0,0 +1,2056 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /* eslint-disable @eslint-react/no-context-provider, @eslint-react/no-use-context, react-refresh/only-export-components -- This package supports React 18 and intentionally keeps public hooks, context provider, and small helper components together in one library module. */
3
+ import { coerceThemeBuilderExportFormat, composeClassName, createThemeBuilderTokens, exportThemeBuilderValue, getLumenRichTextShortcut, isLumenRichTextToggleCommand } from '@santi020k/lumen-core';
4
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
5
+ const defaultToastDuration = 5000;
6
+ const defaultToastMax = 5;
7
+ const closeDelay = 240;
8
+ const ToastContext = createContext(null);
9
+ const focusableSelector = [
10
+ 'a[href]',
11
+ 'button:not([disabled])',
12
+ 'input:not([disabled])',
13
+ 'select:not([disabled])',
14
+ 'textarea:not([disabled])',
15
+ '[tabindex]:not([tabindex="-1"])'
16
+ ].join(',');
17
+ const composeHandlers = (userHandler, lumenHandler) => (event) => {
18
+ userHandler?.(event);
19
+ lumenHandler(event);
20
+ };
21
+ const isElementVisible = (element) => {
22
+ if (typeof element.checkVisibility === 'function') {
23
+ return element.checkVisibility();
24
+ }
25
+ return element.offsetParent !== null || element.getClientRects().length > 0;
26
+ };
27
+ const getFocusable = (root) => {
28
+ if (!root)
29
+ return [];
30
+ return [...root.querySelectorAll(focusableSelector)]
31
+ .filter(element => !element.hasAttribute('hidden') && isElementVisible(element));
32
+ };
33
+ const getLoopedIndex = (key, currentIndex, itemCount, forwardKeys) => {
34
+ const lastIndex = itemCount - 1;
35
+ if (key === 'Home')
36
+ return 0;
37
+ if (key === 'End')
38
+ return lastIndex;
39
+ if (forwardKeys.includes(key))
40
+ return (currentIndex + 1) % itemCount;
41
+ return (currentIndex - 1 + itemCount) % itemCount;
42
+ };
43
+ const getOwnedTarget = (event) => event.target instanceof Node ? event.target : null;
44
+ const useSafeId = (prefix, id) => {
45
+ const reactId = useId().replaceAll(':', '');
46
+ return id ?? `${prefix}-${reactId}`;
47
+ };
48
+ const useControllableState = ({ defaultValue, onChange, value }) => {
49
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
50
+ const currentValue = value ?? uncontrolledValue;
51
+ const setValue = useCallback((next) => {
52
+ const resolvedValue = typeof next === 'function'
53
+ ? next(currentValue)
54
+ : next;
55
+ if (value === undefined) {
56
+ setUncontrolledValue(resolvedValue);
57
+ }
58
+ onChange?.(resolvedValue);
59
+ }, [currentValue, onChange, value]);
60
+ return [currentValue, setValue];
61
+ };
62
+ const focusFirstIn = (root) => {
63
+ globalThis.setTimeout(() => {
64
+ getFocusable(root)[0]?.focus();
65
+ });
66
+ };
67
+ const focusTrigger = (trigger) => {
68
+ trigger?.focus({ preventScroll: true });
69
+ };
70
+ const clampViewportPosition = (value, size, viewportSize) => Math.max(8, Math.min(value, viewportSize - size - 8));
71
+ const getContextMenuPosition = (x, y, rect) => {
72
+ const width = rect?.width ?? 0;
73
+ const height = rect?.height ?? 0;
74
+ const viewportWidth = typeof window === 'undefined' ? x + width + 16 : window.innerWidth;
75
+ const viewportHeight = typeof window === 'undefined' ? y + height + 16 : window.innerHeight;
76
+ return {
77
+ left: clampViewportPosition(x, width, viewportWidth),
78
+ top: clampViewportPosition(y, height, viewportHeight)
79
+ };
80
+ };
81
+ const useOutsideClose = (open, refs, onClose) => {
82
+ useEffect(() => {
83
+ if (!open || typeof document === 'undefined')
84
+ return;
85
+ const handlePointerDown = (event) => {
86
+ const target = getOwnedTarget(event);
87
+ if (!target)
88
+ return;
89
+ const isInside = refs.some(ref => ref.current?.contains(target));
90
+ if (!isInside)
91
+ onClose();
92
+ };
93
+ document.addEventListener('pointerdown', handlePointerDown);
94
+ return () => {
95
+ document.removeEventListener('pointerdown', handlePointerDown);
96
+ };
97
+ }, [onClose, open, refs]);
98
+ };
99
+ const useDisclosureController = (options, hasPopup) => {
100
+ const rootRef = useRef(null);
101
+ const triggerRef = useRef(null);
102
+ const panelRef = useRef(null);
103
+ const panelId = useSafeId(`ui-${hasPopup}`, options.id);
104
+ const [open, setOpen] = useControllableState({
105
+ defaultValue: options.defaultOpen ?? false,
106
+ onChange: options.onOpenChange,
107
+ value: options.open
108
+ });
109
+ const close = useCallback(() => { setOpen(false); }, [setOpen]);
110
+ const toggle = useCallback(() => { setOpen(current => !current); }, [setOpen]);
111
+ useOutsideClose(open, [rootRef, triggerRef, panelRef], close);
112
+ const triggerProps = {
113
+ 'aria-controls': panelId,
114
+ 'aria-expanded': open,
115
+ 'aria-haspopup': hasPopup,
116
+ 'data-ui-trigger': true,
117
+ onClick: () => { toggle(); },
118
+ onKeyDown: (event) => {
119
+ if (event.key !== 'ArrowDown' && event.key !== 'Enter' && event.key !== ' ')
120
+ return;
121
+ event.preventDefault();
122
+ setOpen(true);
123
+ focusFirstIn(panelRef.current);
124
+ },
125
+ ref: triggerRef,
126
+ type: 'button'
127
+ };
128
+ const panelProps = {
129
+ 'data-state': open ? 'open' : 'closed',
130
+ hidden: !open,
131
+ id: panelId,
132
+ onKeyDown: (event) => {
133
+ if (event.key === 'Escape') {
134
+ event.preventDefault();
135
+ close();
136
+ focusTrigger(triggerRef.current);
137
+ return;
138
+ }
139
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
140
+ if (!keys.includes(event.key))
141
+ return;
142
+ const items = getFocusable(panelRef.current);
143
+ if (!items.length)
144
+ return;
145
+ event.preventDefault();
146
+ const currentIndex = items.indexOf(document.activeElement);
147
+ const nextItem = items[getLoopedIndex(event.key, Math.max(0, currentIndex), items.length, ['ArrowDown'])];
148
+ nextItem?.focus();
149
+ },
150
+ ref: panelRef
151
+ };
152
+ return {
153
+ close,
154
+ open,
155
+ panelProps,
156
+ panelRef,
157
+ rootProps: {
158
+ ref: rootRef
159
+ },
160
+ rootRef,
161
+ setOpen,
162
+ toggle,
163
+ triggerProps,
164
+ triggerRef
165
+ };
166
+ };
167
+ export const useDialog = (options = {}) => {
168
+ const dialogRef = useRef(null);
169
+ const triggerRef = useRef(null);
170
+ const [open, setOpen] = useControllableState({
171
+ defaultValue: options.defaultOpen ?? false,
172
+ onChange: options.onOpenChange,
173
+ value: options.open
174
+ });
175
+ const close = useCallback(() => { setOpen(false); }, [setOpen]);
176
+ useEffect(() => {
177
+ const dialog = dialogRef.current;
178
+ if (!dialog)
179
+ return;
180
+ if (open && !dialog.open) {
181
+ if (typeof dialog.showModal === 'function') {
182
+ dialog.showModal();
183
+ }
184
+ else {
185
+ dialog.setAttribute('open', '');
186
+ }
187
+ focusFirstIn(dialog);
188
+ return;
189
+ }
190
+ if (!open && dialog.open) {
191
+ dialog.close();
192
+ }
193
+ }, [open]);
194
+ const dialogProps = {
195
+ 'aria-modal': true,
196
+ 'data-ui-alert-dialog': options.alert ? true : undefined,
197
+ 'data-ui-dialog': options.alert ? undefined : true,
198
+ onClick: (event) => {
199
+ if (event.target === dialogRef.current && !options.alert) {
200
+ close();
201
+ }
202
+ },
203
+ onClose: () => {
204
+ close();
205
+ focusTrigger(triggerRef.current);
206
+ },
207
+ ref: dialogRef,
208
+ role: options.alert ? 'alertdialog' : 'dialog'
209
+ };
210
+ const triggerProps = {
211
+ 'aria-haspopup': 'dialog',
212
+ onClick: () => { setOpen(true); },
213
+ ref: triggerRef,
214
+ type: 'button'
215
+ };
216
+ return {
217
+ close,
218
+ closeProps: {
219
+ onClick: close,
220
+ type: 'button'
221
+ },
222
+ dialogProps,
223
+ dialogRef,
224
+ open,
225
+ setOpen,
226
+ triggerProps,
227
+ triggerRef
228
+ };
229
+ };
230
+ export const usePopover = (options = {}) => useDisclosureController(options, 'menu');
231
+ export const useDropdownMenu = (options = {}) => useDisclosureController(options, 'menu');
232
+ export const useContextMenu = ({ defaultOpen = false, id, onOpenChange, open: controlledOpen } = {}) => {
233
+ const menuRef = useRef(null);
234
+ const triggerRef = useRef(null);
235
+ const menuId = useSafeId('ui-context-menu', id);
236
+ const [position, setPosition] = useState({ left: 0, top: 0 });
237
+ const [open, setOpen] = useControllableState({
238
+ defaultValue: defaultOpen,
239
+ onChange: onOpenChange,
240
+ value: controlledOpen
241
+ });
242
+ const close = useCallback(() => { setOpen(false); }, [setOpen]);
243
+ const openAt = useCallback((x, y) => {
244
+ const rect = menuRef.current?.getBoundingClientRect();
245
+ setPosition(getContextMenuPosition(x, y, rect));
246
+ setOpen(true);
247
+ focusFirstIn(menuRef.current);
248
+ }, [setOpen]);
249
+ const triggerProps = {
250
+ 'aria-controls': menuId,
251
+ 'aria-haspopup': 'menu',
252
+ 'data-ui-context-menu-trigger': menuId,
253
+ onContextMenu: event => {
254
+ event.preventDefault();
255
+ openAt(event.clientX, event.clientY);
256
+ },
257
+ onKeyDown: event => {
258
+ if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10'))
259
+ return;
260
+ event.preventDefault();
261
+ const rect = event.currentTarget.getBoundingClientRect();
262
+ openAt(rect.left + 16, rect.top + 16);
263
+ },
264
+ ref: triggerRef,
265
+ type: 'button'
266
+ };
267
+ const menuProps = {
268
+ 'data-state': open ? 'open' : 'closed',
269
+ 'data-ui-context-menu': true,
270
+ hidden: !open,
271
+ id: menuId,
272
+ onClick: event => {
273
+ if (event.target instanceof HTMLElement && event.target.closest('[role="menuitem"]')) {
274
+ close();
275
+ }
276
+ },
277
+ onKeyDown: event => {
278
+ if (event.key === 'Escape') {
279
+ event.preventDefault();
280
+ close();
281
+ focusTrigger(triggerRef.current);
282
+ return;
283
+ }
284
+ if (!['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key))
285
+ return;
286
+ const items = getFocusable(menuRef.current);
287
+ if (items.length === 0)
288
+ return;
289
+ event.preventDefault();
290
+ const activeElement = typeof document === 'undefined' ? null : document.activeElement;
291
+ const currentIndex = activeElement instanceof HTMLElement ? items.indexOf(activeElement) : -1;
292
+ items[getLoopedIndex(event.key, currentIndex, items.length, ['ArrowDown'])]?.focus();
293
+ },
294
+ ref: menuRef,
295
+ role: 'menu',
296
+ style: {
297
+ left: position.left,
298
+ position: 'fixed',
299
+ top: position.top,
300
+ zIndex: 60
301
+ }
302
+ };
303
+ return {
304
+ close,
305
+ menuProps,
306
+ menuRef,
307
+ open,
308
+ openAt,
309
+ setOpen,
310
+ triggerProps,
311
+ triggerRef
312
+ };
313
+ };
314
+ export const useTabs = ({ defaultValue = '', id, onValueChange, orientation = 'horizontal', value } = {}) => {
315
+ const rootRef = useRef(null);
316
+ const tabsId = useSafeId('ui-tabs', id);
317
+ const [selectedValue, setSelectedValue] = useControllableState({
318
+ defaultValue,
319
+ onChange: onValueChange,
320
+ value
321
+ });
322
+ const activate = useCallback((nextValue) => {
323
+ setSelectedValue(nextValue);
324
+ }, [setSelectedValue]);
325
+ const getTriggerProps = useCallback((triggerValue, props = {}) => {
326
+ const selected = selectedValue === triggerValue;
327
+ const triggerId = `${tabsId}-tab-${triggerValue}`;
328
+ const panelId = `${tabsId}-panel-${triggerValue}`;
329
+ return {
330
+ ...props,
331
+ 'aria-controls': panelId,
332
+ 'aria-selected': selected,
333
+ id: props.id ?? triggerId,
334
+ onClick: composeHandlers(props.onClick, () => { activate(triggerValue); }),
335
+ onKeyDown: composeHandlers(props.onKeyDown, (event) => {
336
+ const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
337
+ if (!keys.includes(event.key))
338
+ return;
339
+ const tabs = rootRef.current
340
+ ? [...rootRef.current.querySelectorAll('[role="tab"]')]
341
+ : [];
342
+ if (!tabs.length)
343
+ return;
344
+ event.preventDefault();
345
+ const currentIndex = tabs.indexOf(event.currentTarget);
346
+ const nextTab = tabs[getLoopedIndex(event.key, Math.max(0, currentIndex), tabs.length, ['ArrowRight'])];
347
+ const nextValue = nextTab?.dataset.value;
348
+ if (!nextTab || !nextValue)
349
+ return;
350
+ activate(nextValue);
351
+ nextTab.focus();
352
+ }),
353
+ role: 'tab',
354
+ tabIndex: selected ? 0 : -1,
355
+ type: props.type ?? 'button',
356
+ 'data-value': triggerValue
357
+ };
358
+ }, [activate, selectedValue, tabsId]);
359
+ const getPanelProps = useCallback((panelValue, props = {}) => {
360
+ const selected = selectedValue === panelValue;
361
+ return {
362
+ ...props,
363
+ 'aria-labelledby': `${tabsId}-tab-${panelValue}`,
364
+ hidden: !selected,
365
+ id: props.id ?? `${tabsId}-panel-${panelValue}`,
366
+ role: 'tabpanel',
367
+ tabIndex: props.tabIndex ?? 0
368
+ };
369
+ }, [selectedValue, tabsId]);
370
+ return {
371
+ getPanelProps,
372
+ getTriggerProps,
373
+ listProps: {
374
+ 'aria-orientation': orientation,
375
+ role: 'tablist'
376
+ },
377
+ rootProps: {
378
+ 'data-ui-tabs': true,
379
+ ref: rootRef
380
+ },
381
+ rootRef,
382
+ setValue: setSelectedValue,
383
+ value: selectedValue
384
+ };
385
+ };
386
+ const normalizeOption = (option) => typeof option === 'string' ? { label: option, value: option } : option;
387
+ const isPrintableKey = (event) => event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey;
388
+ /* eslint-disable complexity -- Select owns the native select, ARIA listbox props, and keyboard/typeahead behavior as one public hook contract. */
389
+ export const useSelect = ({ defaultValue = '', disabled = false, id, name, onValueChange, options = [], placeholder, required = false, value } = {}) => {
390
+ const rootRef = useRef(null);
391
+ const triggerRef = useRef(null);
392
+ const listRef = useRef(null);
393
+ const typeaheadRef = useRef('');
394
+ const typeaheadTimerRef = useRef(undefined);
395
+ const selectId = useSafeId('ui-select', id);
396
+ const listId = `${selectId}-listbox`;
397
+ const normalizedOptions = useMemo(() => options.map(normalizeOption), [options]);
398
+ const [open, setOpen] = useState(false);
399
+ const [selectedValue, setSelectedValue] = useControllableState({
400
+ defaultValue,
401
+ onChange: onValueChange,
402
+ value
403
+ });
404
+ const selectedOption = normalizedOptions.find(option => option.value === selectedValue);
405
+ const triggerText = selectedOption?.label ?? placeholder ?? 'Select an option';
406
+ const hasSelection = Boolean(selectedOption && selectedValue !== '');
407
+ const close = useCallback(() => { setOpen(false); }, [setOpen]);
408
+ const openList = useCallback(() => { setOpen(true); }, [setOpen]);
409
+ useOutsideClose(open, [rootRef, triggerRef, listRef], close);
410
+ useEffect(() => () => {
411
+ if (typeaheadTimerRef.current) {
412
+ clearTimeout(typeaheadTimerRef.current);
413
+ }
414
+ }, []);
415
+ const getEnabledItems = useCallback(() => {
416
+ if (!listRef.current)
417
+ return [];
418
+ return [...listRef.current.querySelectorAll('[data-ui-select-option]')]
419
+ .filter(item => !item.hasAttribute('disabled') && item.getAttribute('aria-disabled') !== 'true');
420
+ }, []);
421
+ const focusOption = useCallback((key, currentItem) => {
422
+ const items = getEnabledItems();
423
+ if (!items.length)
424
+ return;
425
+ const selectedItem = items.find(item => item.getAttribute('aria-selected') === 'true');
426
+ const current = currentItem ?? selectedItem ?? items[0];
427
+ if (!current)
428
+ return;
429
+ const currentIndex = items.indexOf(current);
430
+ const nextItem = items[getLoopedIndex(key, Math.max(0, currentIndex), items.length, ['ArrowDown'])];
431
+ nextItem?.focus();
432
+ }, [getEnabledItems]);
433
+ const focusTypeaheadOption = useCallback((currentItem) => {
434
+ const items = getEnabledItems();
435
+ const typeahead = typeaheadRef.current;
436
+ if (!items.length || !typeahead)
437
+ return;
438
+ const selectedItem = items.find(item => item.getAttribute('aria-selected') === 'true');
439
+ const current = currentItem ?? selectedItem ?? items[0];
440
+ if (!current)
441
+ return;
442
+ const currentIndex = items.indexOf(current);
443
+ const startIndex = Math.max(0, currentIndex + 1);
444
+ const orderedItems = [...items.slice(startIndex), ...items.slice(0, startIndex)];
445
+ const nextItem = orderedItems.find(item => item.textContent.trim().toLowerCase().startsWith(typeahead));
446
+ nextItem?.focus();
447
+ }, [getEnabledItems]);
448
+ const handleTypeahead = useCallback((event, currentItem) => {
449
+ if (!isPrintableKey(event))
450
+ return false;
451
+ event.preventDefault();
452
+ typeaheadRef.current += event.key.toLowerCase();
453
+ if (typeaheadTimerRef.current) {
454
+ clearTimeout(typeaheadTimerRef.current);
455
+ }
456
+ typeaheadTimerRef.current = setTimeout(() => {
457
+ typeaheadRef.current = '';
458
+ }, 700);
459
+ openList();
460
+ focusTypeaheadOption(currentItem);
461
+ return true;
462
+ }, [focusTypeaheadOption, openList]);
463
+ const selectOption = useCallback((nextValue) => {
464
+ const option = normalizedOptions.find(item => item.value === nextValue);
465
+ if (!option || option.disabled)
466
+ return;
467
+ setSelectedValue(nextValue);
468
+ close();
469
+ focusTrigger(triggerRef.current);
470
+ }, [close, normalizedOptions, setSelectedValue]);
471
+ const rootProps = {
472
+ 'data-placeholder': hasSelection ? 'false' : 'true',
473
+ 'data-ui-select': true,
474
+ ref: rootRef
475
+ };
476
+ const controlProps = {
477
+ 'data-ui-select-control': true
478
+ };
479
+ const nativeSelectProps = {
480
+ 'aria-hidden': true,
481
+ 'data-ui-enhanced': true,
482
+ 'data-ui-select-native': true,
483
+ disabled,
484
+ id: selectId,
485
+ name,
486
+ onChange: event => { setSelectedValue(event.currentTarget.value); },
487
+ required,
488
+ tabIndex: -1,
489
+ value: selectedValue
490
+ };
491
+ const triggerProps = {
492
+ 'aria-controls': listId,
493
+ 'aria-expanded': open,
494
+ 'aria-haspopup': 'listbox',
495
+ 'aria-required': required || undefined,
496
+ 'data-ui-select-trigger': true,
497
+ disabled,
498
+ id: `${selectId}-trigger`,
499
+ onClick: () => {
500
+ setOpen(current => !current);
501
+ },
502
+ onKeyDown: (event) => {
503
+ if (handleTypeahead(event))
504
+ return;
505
+ if (event.key === 'Escape') {
506
+ close();
507
+ return;
508
+ }
509
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', ' '];
510
+ if (!keys.includes(event.key))
511
+ return;
512
+ event.preventDefault();
513
+ openList();
514
+ if (event.key === 'Enter' || event.key === ' ') {
515
+ const selectedItem = getEnabledItems().find(item => item.getAttribute('aria-selected') === 'true');
516
+ const firstItem = getEnabledItems()[0];
517
+ (selectedItem ?? firstItem)?.focus();
518
+ return;
519
+ }
520
+ focusOption(event.key);
521
+ },
522
+ ref: triggerRef,
523
+ role: 'combobox',
524
+ type: 'button'
525
+ };
526
+ const listProps = {
527
+ 'data-state': open ? 'open' : 'closed',
528
+ 'data-ui-select-list': true,
529
+ hidden: !open,
530
+ id: listId,
531
+ ref: listRef,
532
+ role: 'listbox'
533
+ };
534
+ const getOptionProps = useCallback((option, props = {}) => ({
535
+ ...props,
536
+ 'aria-disabled': option.disabled ? 'true' : undefined,
537
+ 'aria-selected': selectedValue === option.value,
538
+ 'data-ui-select-option': true,
539
+ 'data-value': option.value,
540
+ disabled: option.disabled,
541
+ onClick: composeHandlers(props.onClick, () => { selectOption(option.value); }),
542
+ onKeyDown: composeHandlers(props.onKeyDown, (event) => {
543
+ if (handleTypeahead(event, event.currentTarget))
544
+ return;
545
+ if (event.key === 'Escape') {
546
+ close();
547
+ focusTrigger(triggerRef.current);
548
+ return;
549
+ }
550
+ if (event.key === 'Enter' || event.key === ' ') {
551
+ event.preventDefault();
552
+ selectOption(option.value);
553
+ return;
554
+ }
555
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
556
+ if (!keys.includes(event.key))
557
+ return;
558
+ event.preventDefault();
559
+ focusOption(event.key, event.currentTarget);
560
+ }),
561
+ role: 'option',
562
+ tabIndex: -1,
563
+ type: props.type ?? 'button'
564
+ }), [close, focusOption, handleTypeahead, selectOption, selectedValue]);
565
+ return {
566
+ close,
567
+ controlProps,
568
+ getOptionProps,
569
+ listProps,
570
+ nativeSelectProps,
571
+ open,
572
+ options: normalizedOptions,
573
+ rootProps,
574
+ rootRef,
575
+ selectOption,
576
+ selectedOption,
577
+ setOpen,
578
+ triggerProps,
579
+ triggerText,
580
+ value: selectedValue
581
+ };
582
+ };
583
+ /* eslint-enable complexity */
584
+ const fieldControlSelector = [
585
+ 'input:not([type="hidden"])',
586
+ 'select',
587
+ 'textarea',
588
+ '[contenteditable="true"]',
589
+ '[role="combobox"]',
590
+ '[role="listbox"]',
591
+ '[role="spinbutton"]',
592
+ '[role="textbox"]'
593
+ ].join(',');
594
+ const fieldDescriptionSelector = '[data-ui-field-hint], [data-ui-field-error], .ui-field__hint, .ui-field__error';
595
+ const fieldErrorSelector = '[data-ui-field-error], .ui-field__error';
596
+ const formControlSelector = [
597
+ 'input:not([type="hidden"])',
598
+ 'select',
599
+ 'textarea'
600
+ ].join(',');
601
+ const validityMessageAttributes = [
602
+ ['valueMissing', 'data-error-required'],
603
+ ['typeMismatch', 'data-error-type'],
604
+ ['patternMismatch', 'data-error-pattern'],
605
+ ['tooShort', 'data-error-too-short'],
606
+ ['tooLong', 'data-error-too-long'],
607
+ ['rangeUnderflow', 'data-error-min'],
608
+ ['rangeOverflow', 'data-error-max'],
609
+ ['stepMismatch', 'data-error-step'],
610
+ ['badInput', 'data-error-bad-input'],
611
+ ['customError', 'data-error-custom']
612
+ ];
613
+ const isNativeFormControl = (element) => element instanceof HTMLInputElement ||
614
+ element instanceof HTMLSelectElement ||
615
+ element instanceof HTMLTextAreaElement;
616
+ const getFieldRoot = (control) => control.closest('.ui-field, [data-ui-field]');
617
+ const getValidationMessage = (control, field) => {
618
+ for (const [validityKey, attributeName] of validityMessageAttributes) {
619
+ if (!control.validity[validityKey])
620
+ continue;
621
+ return control.getAttribute(attributeName) ??
622
+ field?.getAttribute(attributeName) ??
623
+ control.validationMessage;
624
+ }
625
+ return control.validationMessage;
626
+ };
627
+ const getFieldDescriptionId = (element) => {
628
+ if (!element.id) {
629
+ element.id = `ui-field-description-${Math.random().toString(36).slice(2)}`;
630
+ }
631
+ return element.id;
632
+ };
633
+ const syncFieldDescription = (field) => {
634
+ const controlId = field.dataset.uiFieldControl;
635
+ const control = controlId
636
+ ? document.getElementById(controlId)
637
+ : field.querySelector(fieldControlSelector);
638
+ if (!(control instanceof HTMLElement))
639
+ return;
640
+ const existingIds = control.getAttribute('aria-describedby')?.trim().split(/\s+/) ?? [];
641
+ const configuredIds = field.dataset.uiFieldDescribedby?.trim().split(/\s+/) ?? [];
642
+ const descriptionIds = [...field.querySelectorAll(fieldDescriptionSelector)]
643
+ .map(element => getFieldDescriptionId(element));
644
+ const describedBy = [...new Set([...existingIds, ...configuredIds, ...descriptionIds].filter(Boolean))];
645
+ if (describedBy.length) {
646
+ control.setAttribute('aria-describedby', describedBy.join(' '));
647
+ }
648
+ };
649
+ export const useFormValidation = ({ onInvalid, onValid, onValidate } = {}) => {
650
+ const formRef = useRef(null);
651
+ const setFieldValidity = useCallback((control, invalid, message = '') => {
652
+ const field = getFieldRoot(control);
653
+ if (invalid) {
654
+ control.setAttribute('aria-invalid', 'true');
655
+ control.dataset.uiValidationInvalid = 'true';
656
+ }
657
+ else if (control.dataset.uiValidationInvalid === 'true') {
658
+ control.removeAttribute('aria-invalid');
659
+ delete control.dataset.uiValidationInvalid;
660
+ }
661
+ if (!field)
662
+ return;
663
+ field.dataset.invalid = String(invalid);
664
+ for (const error of field.querySelectorAll(fieldErrorSelector)) {
665
+ error.dataset.uiValidationErrorBound = 'true';
666
+ error.hidden = !invalid;
667
+ error.textContent = invalid ? message : '';
668
+ }
669
+ }, []);
670
+ const getControls = useCallback((form = formRef.current) => {
671
+ if (!form)
672
+ return [];
673
+ return [...form.querySelectorAll(formControlSelector)]
674
+ .filter(control => control.form === form && !control.disabled);
675
+ }, []);
676
+ const validateControl = useCallback((control, form = formRef.current ?? control.form) => {
677
+ if (!form)
678
+ return true;
679
+ const validateDetail = { control, form, value: control.value };
680
+ form.dispatchEvent(new CustomEvent('ui:validate', {
681
+ bubbles: true,
682
+ detail: validateDetail
683
+ }));
684
+ onValidate?.(validateDetail);
685
+ const invalid = !control.validity.valid;
686
+ const message = invalid ? getValidationMessage(control, getFieldRoot(control)) : '';
687
+ setFieldValidity(control, invalid, message);
688
+ return !invalid;
689
+ }, [onValidate, setFieldValidity]);
690
+ const validateForm = useCallback((form = formRef.current) => {
691
+ if (!form)
692
+ return [];
693
+ return getControls(form).filter(control => !validateControl(control, form));
694
+ }, [getControls, validateControl]);
695
+ const emitState = useCallback((name, detail) => {
696
+ detail.form.dispatchEvent(new CustomEvent(name, {
697
+ bubbles: true,
698
+ detail
699
+ }));
700
+ if (name === 'ui:invalid') {
701
+ onInvalid?.(detail);
702
+ }
703
+ else {
704
+ onValid?.(detail);
705
+ }
706
+ }, [onInvalid, onValid]);
707
+ const syncFields = useCallback((form) => {
708
+ for (const field of form.querySelectorAll('.ui-field, [data-ui-field]')) {
709
+ syncFieldDescription(field);
710
+ }
711
+ }, []);
712
+ const formProps = {
713
+ 'data-ui-form': true,
714
+ noValidate: true,
715
+ onBlur: (event) => {
716
+ const form = event.currentTarget;
717
+ syncFields(form);
718
+ if (!isNativeFormControl(event.target) || event.target.form !== form)
719
+ return;
720
+ const valid = validateControl(event.target, form);
721
+ emitState(valid ? 'ui:valid' : 'ui:invalid', valid
722
+ ? { control: event.target, form }
723
+ : { control: event.target, controls: [event.target], form });
724
+ },
725
+ onSubmit: (event) => {
726
+ const form = event.currentTarget;
727
+ syncFields(form);
728
+ const invalidControls = validateForm(form);
729
+ if (invalidControls.length) {
730
+ event.preventDefault();
731
+ const firstInvalid = invalidControls[0];
732
+ if (!firstInvalid)
733
+ return;
734
+ emitState('ui:invalid', { control: firstInvalid, controls: invalidControls, form });
735
+ firstInvalid.focus({ preventScroll: true });
736
+ firstInvalid.reportValidity();
737
+ return;
738
+ }
739
+ emitState('ui:valid', { controls: getControls(form), form });
740
+ },
741
+ ref: formRef
742
+ };
743
+ return {
744
+ formProps,
745
+ formRef,
746
+ getControls,
747
+ setFieldValidity,
748
+ validateControl,
749
+ validateForm
750
+ };
751
+ };
752
+ /* eslint-disable @stylistic/padding-line-between-statements, complexity, no-nested-ternary -- Calendar mirrors Astro's UTC date grid and keyboard runtime. */
753
+ const calendarDatePattern = /^\d{4}-\d{2}-\d{2}$/;
754
+ const calendarMonthPattern = /^\d{4}-\d{2}$/;
755
+ const parseCalendarDate = (value) => {
756
+ if (!value || !calendarDatePattern.test(value))
757
+ return null;
758
+ const [year = Number.NaN, month = Number.NaN, day = Number.NaN] = value.split('-').map(Number);
759
+ const date = new Date(Date.UTC(year, month - 1, day));
760
+ return date.getUTCFullYear() === year &&
761
+ date.getUTCMonth() === month - 1 &&
762
+ date.getUTCDate() === day
763
+ ? date
764
+ : null;
765
+ };
766
+ const parseCalendarMonth = (value) => {
767
+ if (!value || !calendarMonthPattern.test(value))
768
+ return null;
769
+ const [year = Number.NaN, month = Number.NaN] = value.split('-').map(Number);
770
+ const date = new Date(Date.UTC(year, month - 1, 1));
771
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 ? date : null;
772
+ };
773
+ const formatCalendarDate = (date) => date.toISOString().slice(0, 10);
774
+ const formatCalendarMonth = (date) => date.toISOString().slice(0, 7);
775
+ const startOfCalendarMonth = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
776
+ const addCalendarDays = (date, days) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
777
+ const getCalendarDaysInMonth = (date) => new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
778
+ const addCalendarMonths = (date, months) => {
779
+ const targetMonth = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
780
+ return new Date(Date.UTC(targetMonth.getUTCFullYear(), targetMonth.getUTCMonth(), Math.min(date.getUTCDate(), getCalendarDaysInMonth(targetMonth))));
781
+ };
782
+ const getCalendarToday = () => {
783
+ const today = new Date();
784
+ return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
785
+ };
786
+ const compareCalendarDates = (date, other) => other ? formatCalendarDate(date).localeCompare(formatCalendarDate(other)) : 0;
787
+ const isCalendarDateDisabled = (disabled, date, min, max) => disabled ||
788
+ compareCalendarDates(date, min) < 0 ||
789
+ compareCalendarDates(date, max) > 0;
790
+ const clampCalendarDate = (date, min, max) => {
791
+ if (min && compareCalendarDates(date, min) < 0)
792
+ return min;
793
+ if (max && compareCalendarDates(date, max) > 0)
794
+ return max;
795
+ return date;
796
+ };
797
+ const getCalendarGridStart = (month) => addCalendarDays(month, -((month.getUTCDay() + 6) % 7));
798
+ const getCalendarLocale = (locale) => {
799
+ if (locale)
800
+ return locale;
801
+ if (typeof document !== 'undefined' && document.documentElement.lang) {
802
+ return document.documentElement.lang;
803
+ }
804
+ if (typeof navigator !== 'undefined' && navigator.language) {
805
+ return navigator.language;
806
+ }
807
+ return 'en';
808
+ };
809
+ const coerceCalendarDate = (value) => value instanceof Date ? value : parseCalendarDate(value);
810
+ const getCalendarFocusDate = (disabled, month, min, max, selectedDate, requestedDate) => {
811
+ const today = getCalendarToday();
812
+ const candidates = [requestedDate, selectedDate, today, month];
813
+ for (const candidate of candidates) {
814
+ if (candidate &&
815
+ formatCalendarMonth(candidate) === formatCalendarMonth(month) &&
816
+ !isCalendarDateDisabled(disabled, candidate, min, max)) {
817
+ return candidate;
818
+ }
819
+ }
820
+ for (let index = 0; index < getCalendarDaysInMonth(month); index += 1) {
821
+ const date = addCalendarDays(month, index);
822
+ if (!isCalendarDateDisabled(disabled, date, min, max))
823
+ return date;
824
+ }
825
+ return month;
826
+ };
827
+ export const useCalendar = ({ defaultValue, disabled = false, locale, max, min, month, name, onValueChange, value: controlledValue } = {}) => {
828
+ const rootRef = useRef(null);
829
+ const generatedId = useId();
830
+ const calendarId = `ui-calendar-${generatedId}`;
831
+ const labelId = `${calendarId}-label`;
832
+ const minDate = useMemo(() => parseCalendarDate(min), [min]);
833
+ const maxDate = useMemo(() => parseCalendarDate(max), [max]);
834
+ const defaultDate = parseCalendarDate(defaultValue);
835
+ const controlledDate = parseCalendarDate(controlledValue);
836
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => defaultDate ? formatCalendarDate(defaultDate) : '');
837
+ const selectedValue = controlledValue === undefined ? uncontrolledValue : controlledDate ? formatCalendarDate(controlledDate) : '';
838
+ const selectedDate = parseCalendarDate(selectedValue);
839
+ const initialMonth = parseCalendarMonth(month) ??
840
+ (selectedDate ? startOfCalendarMonth(selectedDate) : null) ??
841
+ startOfCalendarMonth(getCalendarToday());
842
+ const [visibleMonthValue, setVisibleMonthValue] = useState(() => formatCalendarMonth(initialMonth));
843
+ const visibleMonth = parseCalendarMonth(month ?? visibleMonthValue) ?? initialMonth;
844
+ const [focusValue, setFocusValue] = useState(null);
845
+ const focusDate = getCalendarFocusDate(disabled, visibleMonth, minDate, maxDate, selectedDate, parseCalendarDate(focusValue));
846
+ const focusIso = formatCalendarDate(focusDate);
847
+ const selectedIso = selectedDate ? formatCalendarDate(selectedDate) : '';
848
+ const currentLocale = getCalendarLocale(locale);
849
+ const monthFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, {
850
+ month: 'long',
851
+ timeZone: 'UTC',
852
+ year: 'numeric'
853
+ }), [currentLocale]);
854
+ const dayFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, {
855
+ day: 'numeric',
856
+ month: 'long',
857
+ timeZone: 'UTC',
858
+ weekday: 'long',
859
+ year: 'numeric'
860
+ }), [currentLocale]);
861
+ const weekdayFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, {
862
+ timeZone: 'UTC',
863
+ weekday: 'short'
864
+ }), [currentLocale]);
865
+ const todayIso = formatCalendarDate(getCalendarToday());
866
+ const label = monthFormatter.format(visibleMonth);
867
+ const weekdays = useMemo(() => Array.from({ length: 7 }, (_, index) => weekdayFormatter.format(new Date(Date.UTC(2026, 0, 5 + index)))), [weekdayFormatter]);
868
+ const weeks = useMemo(() => {
869
+ const firstCell = getCalendarGridStart(visibleMonth);
870
+ return Array.from({ length: 6 }, (_, rowIndex) => Array.from({ length: 7 }, (_, columnIndex) => {
871
+ const date = addCalendarDays(firstCell, rowIndex * 7 + columnIndex);
872
+ const dateIso = formatCalendarDate(date);
873
+ const unavailable = isCalendarDateDisabled(disabled, date, minDate, maxDate);
874
+ return {
875
+ date: dateIso,
876
+ day: date.getUTCDate(),
877
+ disabled: unavailable,
878
+ label: dayFormatter.format(date),
879
+ outside: formatCalendarMonth(date) !== formatCalendarMonth(visibleMonth),
880
+ selected: selectedIso === dateIso,
881
+ tabIndex: !unavailable && focusIso === dateIso ? 0 : -1,
882
+ today: todayIso === dateIso
883
+ };
884
+ }));
885
+ }, [dayFormatter, disabled, focusIso, maxDate, minDate, selectedIso, todayIso, visibleMonth]);
886
+ const focusCalendarDate = useCallback((dateInput) => {
887
+ const date = coerceCalendarDate(dateInput);
888
+ if (!date)
889
+ return;
890
+ const nextDate = clampCalendarDate(date, minDate, maxDate);
891
+ setVisibleMonthValue(formatCalendarMonth(startOfCalendarMonth(nextDate)));
892
+ setFocusValue(formatCalendarDate(nextDate));
893
+ }, [maxDate, minDate]);
894
+ const selectDate = useCallback((dateInput) => {
895
+ if (disabled)
896
+ return;
897
+ const date = coerceCalendarDate(dateInput);
898
+ if (!date)
899
+ return;
900
+ const nextDate = clampCalendarDate(date, minDate, maxDate);
901
+ if (isCalendarDateDisabled(disabled, nextDate, minDate, maxDate))
902
+ return;
903
+ const nextValue = formatCalendarDate(nextDate);
904
+ if (controlledValue === undefined) {
905
+ setUncontrolledValue(nextValue);
906
+ }
907
+ setVisibleMonthValue(formatCalendarMonth(startOfCalendarMonth(nextDate)));
908
+ setFocusValue(nextValue);
909
+ onValueChange?.(nextValue);
910
+ }, [controlledValue, disabled, maxDate, minDate, onValueChange]);
911
+ const moveFocus = useCallback((currentDate, key) => {
912
+ const column = (currentDate.getUTCDay() + 6) % 7;
913
+ const keyOffsets = {
914
+ ArrowDown: 7,
915
+ ArrowLeft: -1,
916
+ ArrowRight: 1,
917
+ ArrowUp: -7,
918
+ End: 6 - column,
919
+ Home: -column
920
+ };
921
+ if (key === 'PageDown' || key === 'PageUp') {
922
+ focusCalendarDate(addCalendarMonths(currentDate, key === 'PageDown' ? 1 : -1));
923
+ return;
924
+ }
925
+ const offset = keyOffsets[key];
926
+ if (offset !== undefined) {
927
+ focusCalendarDate(addCalendarDays(currentDate, offset));
928
+ }
929
+ }, [focusCalendarDate]);
930
+ const previousMonthLastDay = addCalendarDays(visibleMonth, -1);
931
+ const nextMonthFirstDay = addCalendarMonths(visibleMonth, 1);
932
+ const previousDisabled = disabled || compareCalendarDates(previousMonthLastDay, minDate) < 0;
933
+ const nextDisabled = disabled || compareCalendarDates(nextMonthFirstDay, maxDate) > 0;
934
+ const getDayProps = useCallback((day, props = {}) => ({
935
+ ...props,
936
+ 'aria-disabled': day.disabled ? true : undefined,
937
+ 'aria-label': props['aria-label'] ?? day.label,
938
+ 'aria-selected': day.selected ? 'true' : 'false',
939
+ 'data-date': day.date,
940
+ 'data-outside': day.outside ? 'true' : undefined,
941
+ 'data-selected': day.selected ? 'true' : undefined,
942
+ 'data-today': day.today ? 'true' : undefined,
943
+ 'data-ui-calendar-day': true,
944
+ onClick: composeHandlers(props.onClick, () => {
945
+ if (!day.disabled) {
946
+ selectDate(day.date);
947
+ }
948
+ }),
949
+ onKeyDown: composeHandlers(props.onKeyDown, event => {
950
+ if (day.disabled)
951
+ return;
952
+ if (event.key === 'Enter' || event.key === ' ') {
953
+ event.preventDefault();
954
+ selectDate(day.date);
955
+ return;
956
+ }
957
+ if (event.key !== 'ArrowDown' &&
958
+ event.key !== 'ArrowLeft' &&
959
+ event.key !== 'ArrowRight' &&
960
+ event.key !== 'ArrowUp' &&
961
+ event.key !== 'End' &&
962
+ event.key !== 'Home' &&
963
+ event.key !== 'PageDown' &&
964
+ event.key !== 'PageUp') {
965
+ return;
966
+ }
967
+ const date = parseCalendarDate(day.date);
968
+ if (!date)
969
+ return;
970
+ event.preventDefault();
971
+ moveFocus(date, event.key);
972
+ }),
973
+ role: 'gridcell',
974
+ tabIndex: props.tabIndex ?? day.tabIndex
975
+ }), [moveFocus, selectDate]);
976
+ return {
977
+ focusDate: focusCalendarDate,
978
+ getDayProps,
979
+ gridProps: {
980
+ 'aria-labelledby': labelId,
981
+ className: 'ui-calendar__grid',
982
+ 'data-ui-calendar-grid': true,
983
+ role: 'grid'
984
+ },
985
+ inputProps: {
986
+ 'data-ui-calendar-input': true,
987
+ disabled,
988
+ name,
989
+ type: 'hidden',
990
+ value: selectedValue
991
+ },
992
+ label,
993
+ labelProps: {
994
+ className: 'ui-calendar__label',
995
+ 'data-ui-calendar-label': true,
996
+ id: labelId
997
+ },
998
+ month: formatCalendarMonth(visibleMonth),
999
+ nextProps: {
1000
+ 'aria-label': 'Next month',
1001
+ className: 'ui-calendar__nav',
1002
+ 'data-ui-calendar-next': true,
1003
+ disabled: nextDisabled,
1004
+ onClick: () => {
1005
+ const nextMonth = startOfCalendarMonth(addCalendarMonths(visibleMonth, 1));
1006
+ setVisibleMonthValue(formatCalendarMonth(nextMonth));
1007
+ setFocusValue(formatCalendarDate(getCalendarFocusDate(disabled, nextMonth, minDate, maxDate, selectedDate)));
1008
+ },
1009
+ type: 'button'
1010
+ },
1011
+ previousProps: {
1012
+ 'aria-label': 'Previous month',
1013
+ className: 'ui-calendar__nav',
1014
+ 'data-ui-calendar-prev': true,
1015
+ disabled: previousDisabled,
1016
+ onClick: () => {
1017
+ const previousMonth = startOfCalendarMonth(addCalendarMonths(visibleMonth, -1));
1018
+ setVisibleMonthValue(formatCalendarMonth(previousMonth));
1019
+ setFocusValue(formatCalendarDate(getCalendarFocusDate(disabled, previousMonth, minDate, maxDate, selectedDate)));
1020
+ },
1021
+ type: 'button'
1022
+ },
1023
+ rootProps: {
1024
+ 'aria-disabled': disabled ? true : undefined,
1025
+ className: 'ui-calendar',
1026
+ 'data-disabled': disabled ? 'true' : undefined,
1027
+ 'data-ui-calendar': true,
1028
+ 'data-ui-calendar-initial-month': formatCalendarMonth(initialMonth),
1029
+ 'data-ui-calendar-max': maxDate ? formatCalendarDate(maxDate) : undefined,
1030
+ 'data-ui-calendar-min': minDate ? formatCalendarDate(minDate) : undefined,
1031
+ 'data-ui-calendar-month': formatCalendarMonth(visibleMonth),
1032
+ 'data-ui-calendar-value': selectedValue || undefined,
1033
+ ref: rootRef
1034
+ },
1035
+ rootRef,
1036
+ selectDate,
1037
+ value: selectedValue,
1038
+ weekdays,
1039
+ weeks
1040
+ };
1041
+ };
1042
+ /* eslint-enable @stylistic/padding-line-between-statements, complexity, no-nested-ternary */
1043
+ const defaultInputOtpLength = 6;
1044
+ const defaultInputOtpPattern = '[0-9]*';
1045
+ const normalizeInputOtpLength = (length) => Math.max(1, Math.floor(Number(length) || defaultInputOtpLength));
1046
+ const isNumericInputOtp = (inputMode, pattern) => inputMode === 'numeric' || pattern === defaultInputOtpPattern;
1047
+ const sanitizeInputOtpValue = (value, length, inputMode, pattern) => {
1048
+ const normalized = isNumericInputOtp(inputMode, pattern)
1049
+ ? value.replaceAll(/\D/g, '')
1050
+ : value.replaceAll(/\s/g, '');
1051
+ return normalized.slice(0, length);
1052
+ };
1053
+ const getInputOtpActiveIndex = (length, value, selectionStart) => Math.min(length - 1, Math.max(0, selectionStart ?? value.length));
1054
+ export const useInputOTP = ({ defaultValue, disabled = false, inputMode = 'numeric', invalid = false, length: lengthOption, onValueChange, pattern = defaultInputOtpPattern, value: controlledValue } = {}) => {
1055
+ const length = normalizeInputOtpLength(lengthOption);
1056
+ const rootRef = useRef(null);
1057
+ const inputRef = useRef(null);
1058
+ const [focused, setFocused] = useState(false);
1059
+ const [activeIndex, setActiveIndex] = useState(0);
1060
+ const [uncontrolledValue, setUncontrolledValue] = useState(() => sanitizeInputOtpValue(defaultValue ?? '', length, inputMode, pattern));
1061
+ const value = sanitizeInputOtpValue(controlledValue ?? uncontrolledValue, length, inputMode, pattern);
1062
+ const segmentIndexes = useMemo(() => Array.from({ length }, (_, index) => index), [length]);
1063
+ const syncValue = useCallback((input = inputRef.current) => {
1064
+ if (!input)
1065
+ return value;
1066
+ const nextValue = sanitizeInputOtpValue(input.value, length, inputMode, pattern);
1067
+ if (input.value !== nextValue) {
1068
+ input.value = nextValue;
1069
+ }
1070
+ if (controlledValue === undefined) {
1071
+ setUncontrolledValue(nextValue);
1072
+ }
1073
+ setActiveIndex(getInputOtpActiveIndex(length, nextValue, input.selectionStart));
1074
+ return nextValue;
1075
+ }, [controlledValue, inputMode, length, pattern, value]);
1076
+ const setSelection = useCallback((index) => {
1077
+ const input = inputRef.current;
1078
+ const position = Math.min(value.length, Math.max(0, index));
1079
+ setActiveIndex(getInputOtpActiveIndex(length, value, position));
1080
+ input?.focus({ preventScroll: true });
1081
+ input?.setSelectionRange(position, position);
1082
+ }, [length, value]);
1083
+ const setValue = useCallback((nextValue) => {
1084
+ const sanitizedValue = sanitizeInputOtpValue(nextValue, length, inputMode, pattern);
1085
+ const input = inputRef.current;
1086
+ if (controlledValue === undefined) {
1087
+ setUncontrolledValue(sanitizedValue);
1088
+ }
1089
+ if (input) {
1090
+ input.value = sanitizedValue;
1091
+ input.setSelectionRange(sanitizedValue.length, sanitizedValue.length);
1092
+ }
1093
+ setActiveIndex(getInputOtpActiveIndex(length, sanitizedValue, sanitizedValue.length));
1094
+ onValueChange?.(sanitizedValue);
1095
+ return sanitizedValue;
1096
+ }, [controlledValue, inputMode, length, onValueChange, pattern]);
1097
+ const commitInputValue = useCallback((input) => {
1098
+ onValueChange?.(syncValue(input));
1099
+ }, [onValueChange, syncValue]);
1100
+ useEffect(() => {
1101
+ const form = inputRef.current?.form;
1102
+ let resetTimer;
1103
+ if (!form)
1104
+ return;
1105
+ const handleReset = () => {
1106
+ resetTimer = globalThis.setTimeout(() => {
1107
+ syncValue(inputRef.current);
1108
+ });
1109
+ };
1110
+ form.addEventListener('reset', handleReset);
1111
+ return () => {
1112
+ if (resetTimer) {
1113
+ globalThis.clearTimeout(resetTimer);
1114
+ }
1115
+ form.removeEventListener('reset', handleReset);
1116
+ };
1117
+ }, [syncValue]);
1118
+ const getInputProps = useCallback((props = {}) => ({
1119
+ ...props,
1120
+ 'aria-invalid': props['aria-invalid'] ?? (invalid ? true : undefined),
1121
+ autoComplete: props.autoComplete ?? 'one-time-code',
1122
+ className: composeClassName('ui-input-otp ui-input-otp__native', props.className),
1123
+ 'data-ui-enhanced': true,
1124
+ 'data-ui-input-otp-native': true,
1125
+ disabled: props.disabled ?? disabled,
1126
+ inputMode: props.inputMode ?? inputMode,
1127
+ maxLength: props.maxLength ?? length,
1128
+ onBlur: composeHandlers(props.onBlur, event => {
1129
+ setFocused(false);
1130
+ syncValue(event.currentTarget);
1131
+ }),
1132
+ onChange: composeHandlers(props.onChange, event => {
1133
+ commitInputValue(event.currentTarget);
1134
+ }),
1135
+ onClick: composeHandlers(props.onClick, event => {
1136
+ syncValue(event.currentTarget);
1137
+ }),
1138
+ onFocus: composeHandlers(props.onFocus, event => {
1139
+ setFocused(true);
1140
+ syncValue(event.currentTarget);
1141
+ }),
1142
+ onInput: composeHandlers(props.onInput, event => {
1143
+ commitInputValue(event.currentTarget);
1144
+ }),
1145
+ onKeyDown: composeHandlers(props.onKeyDown, event => {
1146
+ if (event.key === 'ArrowLeft') {
1147
+ event.preventDefault();
1148
+ setSelection((event.currentTarget.selectionStart ?? event.currentTarget.value.length) - 1);
1149
+ return;
1150
+ }
1151
+ if (event.key === 'ArrowRight') {
1152
+ event.preventDefault();
1153
+ setSelection((event.currentTarget.selectionStart ?? event.currentTarget.value.length) + 1);
1154
+ return;
1155
+ }
1156
+ if (event.key === 'Home') {
1157
+ event.preventDefault();
1158
+ setSelection(0);
1159
+ return;
1160
+ }
1161
+ if (event.key === 'End') {
1162
+ event.preventDefault();
1163
+ setSelection(event.currentTarget.value.length);
1164
+ }
1165
+ }),
1166
+ onKeyUp: composeHandlers(props.onKeyUp, event => {
1167
+ syncValue(event.currentTarget);
1168
+ }),
1169
+ onPaste: composeHandlers(props.onPaste, event => {
1170
+ const pasted = event.clipboardData.getData('text');
1171
+ if (!pasted)
1172
+ return;
1173
+ event.preventDefault();
1174
+ setValue(pasted);
1175
+ }),
1176
+ pattern: props.pattern ?? pattern,
1177
+ ref: inputRef,
1178
+ type: 'text',
1179
+ value
1180
+ }), [commitInputValue, disabled, inputMode, invalid, length, pattern, setSelection, setValue, syncValue, value]);
1181
+ const getSegmentChar = useCallback(index => value[index] ?? '\u00a0', [value]);
1182
+ const getSegmentProps = useCallback((index, props = {}) => ({
1183
+ ...props,
1184
+ 'aria-hidden': true,
1185
+ className: composeClassName('ui-input-otp__segment', props.className),
1186
+ 'data-active': String(focused && index === activeIndex),
1187
+ 'data-index': index,
1188
+ 'data-ui-input-otp-segment': true,
1189
+ disabled: props.disabled ?? disabled,
1190
+ onClick: composeHandlers(props.onClick, () => {
1191
+ setSelection(index);
1192
+ }),
1193
+ tabIndex: props.tabIndex ?? -1,
1194
+ type: 'button'
1195
+ }), [activeIndex, disabled, focused, setSelection]);
1196
+ return {
1197
+ activeIndex,
1198
+ getInputProps,
1199
+ getSegmentChar,
1200
+ getSegmentProps,
1201
+ inputRef,
1202
+ rootProps: {
1203
+ className: 'ui-input-otp-field',
1204
+ 'data-disabled': disabled ? 'true' : 'false',
1205
+ 'data-invalid': invalid ? 'true' : 'false',
1206
+ 'data-ui-input-otp': true,
1207
+ 'data-ui-input-otp-length': length,
1208
+ ref: rootRef
1209
+ },
1210
+ rootRef,
1211
+ segmentIndexes,
1212
+ segmentsProps: {
1213
+ className: 'ui-input-otp__segments',
1214
+ 'data-ui-input-otp-segments': true
1215
+ },
1216
+ setSelection,
1217
+ setValue,
1218
+ syncValue,
1219
+ value
1220
+ };
1221
+ };
1222
+ const getDateRangeRoot = (control, fallback) => control.closest('[data-ui-date-range-picker]') ?? fallback;
1223
+ const getDateRangeInputs = (root, start, end) => {
1224
+ if (start && end && start !== end)
1225
+ return [start, end];
1226
+ const inputs = root ? [...root.querySelectorAll('input[type="date"]')] : [];
1227
+ return [
1228
+ start ?? inputs[0] ?? null,
1229
+ end ?? inputs.at(-1) ?? null
1230
+ ];
1231
+ };
1232
+ const syncDateInputConstraint = (input, property, value) => {
1233
+ if (value) {
1234
+ input[property] = value;
1235
+ return;
1236
+ }
1237
+ input.removeAttribute(property);
1238
+ };
1239
+ const syncDateRangeInputs = (start, end) => {
1240
+ if (!start || !end || start === end)
1241
+ return {};
1242
+ syncDateInputConstraint(end, 'min', start.value);
1243
+ syncDateInputConstraint(start, 'max', end.value);
1244
+ if (start.value && end.value && end.value < start.value) {
1245
+ end.value = start.value;
1246
+ }
1247
+ return {
1248
+ ...(end.value ? { end: end.value } : {}),
1249
+ ...(start.value ? { start: start.value } : {})
1250
+ };
1251
+ };
1252
+ export const useDateRangePicker = ({ onRangeChange } = {}) => {
1253
+ const rootRef = useRef(null);
1254
+ const startRef = useRef(null);
1255
+ const endRef = useRef(null);
1256
+ const syncRange = useCallback((root = rootRef.current) => {
1257
+ const [start, end] = getDateRangeInputs(root, startRef.current, endRef.current);
1258
+ const detail = syncDateRangeInputs(start, end);
1259
+ onRangeChange?.(detail);
1260
+ return detail;
1261
+ }, [onRangeChange]);
1262
+ const getStartProps = useCallback((props = {}) => ({
1263
+ ...props,
1264
+ onChange: composeHandlers(props.onChange, event => {
1265
+ startRef.current = event.currentTarget;
1266
+ syncRange(getDateRangeRoot(event.currentTarget, rootRef.current));
1267
+ }),
1268
+ ref: startRef,
1269
+ type: props.type ?? 'date'
1270
+ }), [syncRange]);
1271
+ const getEndProps = useCallback((props = {}) => ({
1272
+ ...props,
1273
+ onChange: composeHandlers(props.onChange, event => {
1274
+ endRef.current = event.currentTarget;
1275
+ syncRange(getDateRangeRoot(event.currentTarget, rootRef.current));
1276
+ }),
1277
+ ref: endRef,
1278
+ type: props.type ?? 'date'
1279
+ }), [syncRange]);
1280
+ return {
1281
+ endRef,
1282
+ getEndProps,
1283
+ getStartProps,
1284
+ rootProps: {
1285
+ 'data-ui-date-range-picker': true,
1286
+ ref: rootRef
1287
+ },
1288
+ rootRef,
1289
+ startRef,
1290
+ syncRange
1291
+ };
1292
+ };
1293
+ const richTextEditorContentSelector = '[data-ui-rich-text-editable], [contenteditable="true"]';
1294
+ const getRichTextChangeDetail = (root) => {
1295
+ const editable = root?.querySelector(richTextEditorContentSelector);
1296
+ if (!editable)
1297
+ return null;
1298
+ return {
1299
+ html: editable.innerHTML,
1300
+ text: editable.textContent
1301
+ };
1302
+ };
1303
+ const executeRichTextDocumentCommand = (commandDocument, command, value) => {
1304
+ if (typeof commandDocument?.execCommand !== 'function')
1305
+ return false;
1306
+ return value === undefined
1307
+ ? commandDocument.execCommand(command)
1308
+ : commandDocument.execCommand(command, false, value);
1309
+ };
1310
+ // eslint-disable-next-line complexity -- Command-state normalization covers toggle and value-bearing controls together.
1311
+ const syncRichTextCommandStates = (root) => {
1312
+ if (!root || typeof document === 'undefined')
1313
+ return;
1314
+ const commandDocument = document;
1315
+ for (const control of root.querySelectorAll('[data-ui-editor-command]')) {
1316
+ const command = control.dataset.uiEditorCommand ?? '';
1317
+ const value = control.dataset.uiEditorValue;
1318
+ let active = false;
1319
+ if (isLumenRichTextToggleCommand(command)) {
1320
+ active = commandDocument.queryCommandState?.(command) ?? false;
1321
+ control.setAttribute('aria-pressed', String(active));
1322
+ }
1323
+ else if (command === 'formatBlock' && value) {
1324
+ const currentValue = commandDocument.queryCommandValue?.(command)
1325
+ .replaceAll(/[<>]/g, '')
1326
+ .toLowerCase();
1327
+ active = currentValue === value.replaceAll(/[<>]/g, '').toLowerCase();
1328
+ }
1329
+ control.dataset.state = active ? 'on' : 'off';
1330
+ }
1331
+ };
1332
+ export const useRichTextEditor = ({ onChange, onCommand } = {}) => {
1333
+ const rootRef = useRef(null);
1334
+ const emitChange = useCallback((root) => {
1335
+ const detail = getRichTextChangeDetail(root);
1336
+ if (!detail)
1337
+ return;
1338
+ if (typeof CustomEvent !== 'undefined') {
1339
+ root?.dispatchEvent(new CustomEvent('ui:editor-change', {
1340
+ bubbles: true,
1341
+ detail
1342
+ }));
1343
+ }
1344
+ onChange?.(detail);
1345
+ }, [onChange]);
1346
+ const executeCommand = useCallback((command, root = rootRef.current, value) => {
1347
+ if (!command)
1348
+ return false;
1349
+ const commandDocument = typeof document === 'undefined'
1350
+ ? undefined
1351
+ : document;
1352
+ const executed = executeRichTextDocumentCommand(commandDocument, command, value);
1353
+ const detail = {
1354
+ command,
1355
+ executed,
1356
+ ...(value === undefined ? {} : { value })
1357
+ };
1358
+ if (root && typeof CustomEvent !== 'undefined') {
1359
+ root.dispatchEvent(new CustomEvent('ui:editor-command', {
1360
+ bubbles: true,
1361
+ detail
1362
+ }));
1363
+ }
1364
+ onCommand?.(detail);
1365
+ syncRichTextCommandStates(root);
1366
+ emitChange(root);
1367
+ return executed;
1368
+ }, [emitChange, onCommand]);
1369
+ const getCommandProps = useCallback((command, props = {}) => ({
1370
+ ...props,
1371
+ 'data-ui-editor-command': command,
1372
+ onClick: composeHandlers(props.onClick, event => {
1373
+ const root = event.currentTarget.closest('[data-ui-rich-text-editor]') ?? rootRef.current;
1374
+ executeCommand(command, root, event.currentTarget.dataset.uiEditorValue);
1375
+ }),
1376
+ type: props.type ?? 'button'
1377
+ }), [executeCommand]);
1378
+ const getEditableProps = useCallback((props = {}) => ({
1379
+ ...props,
1380
+ 'aria-multiline': props['aria-multiline'] ?? true,
1381
+ contentEditable: props.contentEditable ?? true,
1382
+ 'data-ui-rich-text-editable': true,
1383
+ onInput: composeHandlers(props.onInput, event => {
1384
+ const root = event.currentTarget.closest('[data-ui-rich-text-editor]') ?? rootRef.current;
1385
+ syncRichTextCommandStates(root);
1386
+ emitChange(root);
1387
+ }),
1388
+ onKeyDown: composeHandlers(props.onKeyDown, event => {
1389
+ const command = getLumenRichTextShortcut(event);
1390
+ if (!command)
1391
+ return;
1392
+ event.preventDefault();
1393
+ const root = event.currentTarget.closest('[data-ui-rich-text-editor]') ?? rootRef.current;
1394
+ executeCommand(command, root);
1395
+ }),
1396
+ onKeyUp: composeHandlers(props.onKeyUp, event => {
1397
+ syncRichTextCommandStates(event.currentTarget.closest('[data-ui-rich-text-editor]') ?? rootRef.current);
1398
+ }),
1399
+ onMouseUp: composeHandlers(props.onMouseUp, event => {
1400
+ syncRichTextCommandStates(event.currentTarget.closest('[data-ui-rich-text-editor]') ?? rootRef.current);
1401
+ }),
1402
+ role: props.role ?? 'textbox',
1403
+ suppressContentEditableWarning: props.suppressContentEditableWarning ?? true
1404
+ }), [emitChange, executeCommand]);
1405
+ return {
1406
+ executeCommand,
1407
+ getCommandProps,
1408
+ getEditableProps,
1409
+ rootProps: {
1410
+ 'data-ui-rich-text-editor': true,
1411
+ ref: rootRef
1412
+ },
1413
+ rootRef
1414
+ };
1415
+ };
1416
+ const getScheduleRoot = (element, fallback) => element.closest('[data-ui-schedule]') ?? fallback;
1417
+ export const useSchedule = ({ onChange } = {}) => {
1418
+ const rootRef = useRef(null);
1419
+ const emitChange = useCallback((detail, root = rootRef.current) => {
1420
+ if (root && typeof CustomEvent !== 'undefined') {
1421
+ root.dispatchEvent(new CustomEvent('ui:schedule-change', {
1422
+ bubbles: true,
1423
+ detail
1424
+ }));
1425
+ }
1426
+ onChange?.(detail);
1427
+ }, [onChange]);
1428
+ const getEventProps = useCallback((eventId, props = {}) => ({
1429
+ ...props,
1430
+ ...(eventId && !props.id ? { id: eventId } : {}),
1431
+ 'data-ui-draggable': true,
1432
+ 'data-ui-schedule-event': true,
1433
+ draggable: props.draggable ?? true,
1434
+ onDragEnd: composeHandlers(props.onDragEnd, event => {
1435
+ const root = getScheduleRoot(event.currentTarget, rootRef.current);
1436
+ if (root) {
1437
+ delete root.dataset.uiDragging;
1438
+ }
1439
+ }),
1440
+ onDragStart: composeHandlers(props.onDragStart, event => {
1441
+ const transferValue = eventId || event.currentTarget.id || event.currentTarget.textContent.trim();
1442
+ const root = getScheduleRoot(event.currentTarget, rootRef.current);
1443
+ event.dataTransfer.setData('text/plain', transferValue);
1444
+ if (root) {
1445
+ root.dataset.uiDragging = 'true';
1446
+ }
1447
+ })
1448
+ }), []);
1449
+ const getSlotProps = useCallback((slot, props = {}) => ({
1450
+ ...props,
1451
+ 'data-ui-schedule-slot': slot,
1452
+ onDragLeave: composeHandlers(props.onDragLeave, event => {
1453
+ delete event.currentTarget.dataset.state;
1454
+ }),
1455
+ onDragOver: composeHandlers(props.onDragOver, event => {
1456
+ event.preventDefault();
1457
+ event.currentTarget.dataset.state = 'drag-over';
1458
+ }),
1459
+ onDrop: composeHandlers(props.onDrop, event => {
1460
+ event.preventDefault();
1461
+ delete event.currentTarget.dataset.state;
1462
+ const draggedId = event.dataTransfer.getData('text/plain');
1463
+ const dragged = typeof document !== 'undefined' && draggedId
1464
+ ? document.getElementById(draggedId)
1465
+ : null;
1466
+ if (typeof HTMLElement !== 'undefined' && dragged instanceof HTMLElement) {
1467
+ event.currentTarget.append(dragged);
1468
+ }
1469
+ emitChange({
1470
+ ...(draggedId ? { eventId: draggedId } : {}),
1471
+ ...(slot ? { slot } : {})
1472
+ }, getScheduleRoot(event.currentTarget, rootRef.current));
1473
+ })
1474
+ }), [emitChange]);
1475
+ return {
1476
+ emitChange,
1477
+ getEventProps,
1478
+ getSlotProps,
1479
+ rootProps: {
1480
+ 'data-ui-schedule': true,
1481
+ ref: rootRef
1482
+ },
1483
+ rootRef
1484
+ };
1485
+ };
1486
+ /* eslint-disable @stylistic/padding-line-between-statements, @eslint-react/set-state-in-effect, @typescript-eslint/prefer-optional-chain, complexity, no-nested-ternary -- Resizable mirrors Astro's compact pane sizing runtime. */
1487
+ const parseResizableNumberList = (value, count, fallback) => {
1488
+ const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
1489
+ return Array.from({ length: count }, (_, index) => values[index] ?? values[0] ?? fallback);
1490
+ };
1491
+ const normalizeResizableSizes = (sizes, count) => {
1492
+ const fallbackSize = 100 / Math.max(1, count);
1493
+ const usableSizes = Array.from({ length: count }, (_, index) => {
1494
+ const size = sizes[index] ?? fallbackSize;
1495
+ return Number.isFinite(size) && size > 0 ? size : fallbackSize;
1496
+ });
1497
+ const total = usableSizes.reduce((sum, size) => sum + size, 0);
1498
+ if (total <= 0)
1499
+ return Array.from({ length: count }, () => fallbackSize);
1500
+ return usableSizes.map(size => (size / total) * 100);
1501
+ };
1502
+ const serializeResizableSize = (value) => Array.isArray(value) ? value.join(',') : value === undefined ? undefined : String(value);
1503
+ export const useResizable = ({ defaultSizes, direction = 'horizontal', maxSize, minSize, onSizesChange, panelCount: panelCountOption = 2, resetOnDoubleClick = true } = {}) => {
1504
+ const panelCount = Math.max(0, Math.floor(panelCountOption));
1505
+ const rootRef = useRef(null);
1506
+ const dragRef = useRef(null);
1507
+ const minSizes = useMemo(() => parseResizableNumberList(minSize, panelCount, 12), [minSize, panelCount]);
1508
+ const maxSizes = useMemo(() => parseResizableNumberList(maxSize, panelCount, 88), [maxSize, panelCount]);
1509
+ const initialSizes = useMemo(() => normalizeResizableSizes(parseResizableNumberList(defaultSizes, panelCount, 100 / Math.max(1, panelCount)), panelCount), [defaultSizes, panelCount]);
1510
+ const [sizes, setSizes] = useState(initialSizes);
1511
+ const axis = direction === 'horizontal' ? 'clientX' : 'clientY';
1512
+ const sizeProperty = direction === 'horizontal' ? 'width' : 'height';
1513
+ const separatorOrientation = direction === 'horizontal' ? 'vertical' : 'horizontal';
1514
+ const panelIndexes = useMemo(() => Array.from({ length: panelCount }, (_, index) => index), [panelCount]);
1515
+ const handleIndexes = useMemo(() => Array.from({ length: Math.max(0, panelCount - 1) }, (_, index) => index), [panelCount]);
1516
+ useEffect(() => {
1517
+ setSizes(initialSizes);
1518
+ }, [initialSizes]);
1519
+ const updateSizes = useCallback((updater) => {
1520
+ setSizes(currentSizes => {
1521
+ const nextSizes = updater(currentSizes);
1522
+ onSizesChange?.(nextSizes);
1523
+ return nextSizes;
1524
+ });
1525
+ }, [onSizesChange]);
1526
+ const resizePair = useCallback((index, nextSize) => {
1527
+ updateSizes(currentSizes => {
1528
+ const nextSizes = [...currentSizes];
1529
+ const nextIndex = index + 1;
1530
+ const total = (nextSizes[index] ?? 0) + (nextSizes[nextIndex] ?? 0);
1531
+ const min = Math.max(minSizes[index] ?? 0, total - (maxSizes[nextIndex] ?? 100));
1532
+ const max = Math.min(maxSizes[index] ?? 100, total - (minSizes[nextIndex] ?? 0));
1533
+ const paneSize = Math.min(max, Math.max(min, nextSize));
1534
+ nextSizes[index] = paneSize;
1535
+ nextSizes[nextIndex] = total - paneSize;
1536
+ return nextSizes;
1537
+ });
1538
+ }, [maxSizes, minSizes, updateSizes]);
1539
+ const reset = useCallback(() => {
1540
+ updateSizes(() => [...initialSizes]);
1541
+ }, [initialSizes, updateSizes]);
1542
+ const getPanelProps = useCallback((index, props = {}) => ({
1543
+ ...props,
1544
+ 'data-ui-resizable-panel': true,
1545
+ style: {
1546
+ ...props.style,
1547
+ '--ui-resizable-size': `${sizes[index] ?? 0}%`
1548
+ }
1549
+ }), [sizes]);
1550
+ const getHandleProps = useCallback((index, props = {}) => ({
1551
+ ...props,
1552
+ 'aria-label': props['aria-label'] ?? `Resize panel ${index + 1}`,
1553
+ 'aria-orientation': separatorOrientation,
1554
+ 'aria-valuemax': Math.round(maxSizes[index] ?? 100),
1555
+ 'aria-valuemin': Math.round(minSizes[index] ?? 0),
1556
+ 'aria-valuenow': Math.round(sizes[index] ?? 0),
1557
+ className: composeClassName('ui-resizable__handle', props.className),
1558
+ 'data-index': index,
1559
+ 'data-ui-resizable-handle': true,
1560
+ onDoubleClick: composeHandlers(props.onDoubleClick, () => {
1561
+ if (resetOnDoubleClick) {
1562
+ reset();
1563
+ }
1564
+ }),
1565
+ onKeyDown: composeHandlers(props.onKeyDown, event => {
1566
+ const step = event.shiftKey ? 10 : 2;
1567
+ const keyDeltas = direction === 'horizontal'
1568
+ ? { ArrowLeft: -step, ArrowRight: step }
1569
+ : { ArrowDown: step, ArrowUp: -step };
1570
+ if (event.key === 'Home') {
1571
+ event.preventDefault();
1572
+ resizePair(index, minSizes[index] ?? 0);
1573
+ return;
1574
+ }
1575
+ if (event.key === 'End') {
1576
+ event.preventDefault();
1577
+ resizePair(index, maxSizes[index] ?? 100);
1578
+ return;
1579
+ }
1580
+ const delta = keyDeltas[event.key];
1581
+ if (delta === undefined)
1582
+ return;
1583
+ event.preventDefault();
1584
+ resizePair(index, (sizes[index] ?? 0) + delta);
1585
+ }),
1586
+ onPointerDown: composeHandlers(props.onPointerDown, event => {
1587
+ if (event.button !== 0)
1588
+ return;
1589
+ event.preventDefault();
1590
+ dragRef.current = {
1591
+ containerSize: Math.max(1, rootRef.current?.getBoundingClientRect()[sizeProperty] ?? 1),
1592
+ index,
1593
+ startPosition: event[axis],
1594
+ startSize: sizes[index] ?? 0
1595
+ };
1596
+ if (rootRef.current) {
1597
+ rootRef.current.dataset.resizing = 'true';
1598
+ }
1599
+ event.currentTarget.dataset.active = 'true';
1600
+ event.currentTarget.setPointerCapture(event.pointerId);
1601
+ }),
1602
+ onPointerMove: composeHandlers(props.onPointerMove, event => {
1603
+ const drag = dragRef.current;
1604
+ if (!drag || drag.index !== index || event.currentTarget.dataset.active !== 'true')
1605
+ return;
1606
+ const delta = ((event[axis] - drag.startPosition) / drag.containerSize) * 100;
1607
+ resizePair(index, drag.startSize + delta);
1608
+ }),
1609
+ onPointerUp: composeHandlers(props.onPointerUp, event => {
1610
+ if (event.currentTarget.dataset.active !== 'true')
1611
+ return;
1612
+ delete event.currentTarget.dataset.active;
1613
+ if (rootRef.current) {
1614
+ delete rootRef.current.dataset.resizing;
1615
+ }
1616
+ dragRef.current = null;
1617
+ event.currentTarget.releasePointerCapture(event.pointerId);
1618
+ }),
1619
+ role: 'separator',
1620
+ tabIndex: props.tabIndex ?? 0,
1621
+ type: props.type ?? 'button'
1622
+ }), [
1623
+ axis,
1624
+ direction,
1625
+ maxSizes,
1626
+ minSizes,
1627
+ reset,
1628
+ resetOnDoubleClick,
1629
+ resizePair,
1630
+ separatorOrientation,
1631
+ sizeProperty,
1632
+ sizes
1633
+ ]);
1634
+ return {
1635
+ direction,
1636
+ getHandleProps,
1637
+ getPanelProps,
1638
+ handleIndexes,
1639
+ panelIndexes,
1640
+ reset,
1641
+ resizePair,
1642
+ rootProps: {
1643
+ className: composeClassName('ui-resizable', direction === 'vertical' && 'ui-resizable--vertical'),
1644
+ 'data-orientation': direction,
1645
+ 'data-ui-resizable': true,
1646
+ 'data-ui-resizable-default-sizes': defaultSizes?.join(','),
1647
+ 'data-ui-resizable-enhanced': true,
1648
+ 'data-ui-resizable-max-size': serializeResizableSize(maxSize),
1649
+ 'data-ui-resizable-min-size': serializeResizableSize(minSize),
1650
+ 'data-ui-resizable-reset': resetOnDoubleClick ? 'true' : undefined,
1651
+ ref: rootRef
1652
+ },
1653
+ rootRef,
1654
+ sizes
1655
+ };
1656
+ };
1657
+ /* eslint-enable @stylistic/padding-line-between-statements, @eslint-react/set-state-in-effect, @typescript-eslint/prefer-optional-chain, complexity, no-nested-ternary */
1658
+ export const useThemeBuilder = ({ accentHue, defaultAccentHue = 54, defaultExportFormat = 'css', defaultHue = 264, defaultMode = 'generated', defaultPrimaryColor = '#6f20f0', defaultScheme = 'light', defaultSecondaryColor = '#14b8a6', exportFormat, hue, mode, onAccentHueChange, onExportFormatChange, onHueChange, onModeChange, onPrimaryColorChange, onSchemeChange, onSecondaryColorChange, onThemeChange, onThemeExport, primaryColor, scheme, secondaryColor } = {}) => {
1659
+ const [currentHue, setHue] = useControllableState({
1660
+ defaultValue: defaultHue,
1661
+ onChange: onHueChange,
1662
+ value: hue
1663
+ });
1664
+ const [currentAccentHue, setAccentHue] = useControllableState({
1665
+ defaultValue: defaultAccentHue,
1666
+ onChange: onAccentHueChange,
1667
+ value: accentHue
1668
+ });
1669
+ const [currentMode, setMode] = useControllableState({
1670
+ defaultValue: defaultMode,
1671
+ onChange: onModeChange,
1672
+ value: mode
1673
+ });
1674
+ const [currentScheme, setScheme] = useControllableState({
1675
+ defaultValue: defaultScheme,
1676
+ onChange: onSchemeChange,
1677
+ value: scheme
1678
+ });
1679
+ const [currentPrimaryColor, setPrimaryColor] = useControllableState({
1680
+ defaultValue: defaultPrimaryColor,
1681
+ onChange: onPrimaryColorChange,
1682
+ value: primaryColor
1683
+ });
1684
+ const [currentSecondaryColor, setSecondaryColor] = useControllableState({
1685
+ defaultValue: defaultSecondaryColor,
1686
+ onChange: onSecondaryColorChange,
1687
+ value: secondaryColor
1688
+ });
1689
+ const [currentExportFormat, setExportFormat] = useControllableState({
1690
+ defaultValue: defaultExportFormat,
1691
+ onChange: onExportFormatChange,
1692
+ value: exportFormat
1693
+ });
1694
+ const result = useMemo(() => createThemeBuilderTokens({
1695
+ accentHue: currentAccentHue,
1696
+ hue: currentHue,
1697
+ mode: currentMode,
1698
+ primaryColor: currentPrimaryColor,
1699
+ scheme: currentScheme,
1700
+ secondaryColor: currentSecondaryColor
1701
+ }), [
1702
+ currentAccentHue,
1703
+ currentHue,
1704
+ currentMode,
1705
+ currentPrimaryColor,
1706
+ currentScheme,
1707
+ currentSecondaryColor
1708
+ ]);
1709
+ const exportValue = useMemo(() => exportThemeBuilderValue(result.tokens, result.scheme, currentExportFormat), [currentExportFormat, result.scheme, result.tokens]);
1710
+ const previewStyle = useMemo(() => Object.fromEntries(Object.entries(result.tokens).map(([token, value]) => [`--${token}`, value])), [result.tokens]);
1711
+ useEffect(() => {
1712
+ onThemeChange?.(result);
1713
+ }, [onThemeChange, result]);
1714
+ const exportDetail = useMemo(() => ({
1715
+ ...(currentExportFormat === 'css' ? { css: exportValue } : {}),
1716
+ format: currentExportFormat,
1717
+ tokens: result.tokens,
1718
+ value: exportValue
1719
+ }), [currentExportFormat, exportValue, result.tokens]);
1720
+ const copyExport = useCallback(async () => {
1721
+ if (typeof navigator !== 'undefined') {
1722
+ await navigator.clipboard.writeText(exportValue).catch(() => null);
1723
+ }
1724
+ onThemeExport?.(exportDetail);
1725
+ return exportValue;
1726
+ }, [exportDetail, exportValue, onThemeExport]);
1727
+ const getModeProps = useCallback((nextMode, props = {}) => ({
1728
+ ...props,
1729
+ 'aria-pressed': currentMode === nextMode,
1730
+ 'data-ui-theme-mode': nextMode,
1731
+ onClick: composeHandlers(props.onClick, () => { setMode(nextMode); }),
1732
+ type: props.type ?? 'button'
1733
+ }), [currentMode, setMode]);
1734
+ const getSchemeProps = useCallback((nextScheme, props = {}) => ({
1735
+ ...props,
1736
+ 'aria-pressed': currentScheme === nextScheme,
1737
+ 'data-ui-theme-scheme': nextScheme,
1738
+ onClick: composeHandlers(props.onClick, () => { setScheme(nextScheme); }),
1739
+ type: props.type ?? 'button'
1740
+ }), [currentScheme, setScheme]);
1741
+ const getExportFormatProps = useCallback((format, props = {}) => ({
1742
+ ...props,
1743
+ 'aria-pressed': currentExportFormat === format,
1744
+ 'data-ui-theme-export-format': format,
1745
+ onClick: composeHandlers(props.onClick, () => { setExportFormat(coerceThemeBuilderExportFormat(format)); }),
1746
+ type: props.type ?? 'button'
1747
+ }), [currentExportFormat, setExportFormat]);
1748
+ return {
1749
+ ...result,
1750
+ accentHueProps: {
1751
+ 'data-ui-theme-accent-hue': true,
1752
+ max: 359,
1753
+ min: 0,
1754
+ onChange: event => { setAccentHue(Number(event.currentTarget.value)); },
1755
+ type: 'range',
1756
+ value: currentAccentHue
1757
+ },
1758
+ copyExport,
1759
+ exportButtonProps: {
1760
+ 'data-ui-theme-export': true,
1761
+ onClick: () => {
1762
+ copyExport().catch(() => null);
1763
+ },
1764
+ type: 'button'
1765
+ },
1766
+ exportFormat: currentExportFormat,
1767
+ exportValue,
1768
+ getExportFormatProps,
1769
+ getModeProps,
1770
+ getSchemeProps,
1771
+ hueProps: {
1772
+ 'data-ui-theme-brand-hue': true,
1773
+ max: 359,
1774
+ min: 0,
1775
+ onChange: event => { setHue(Number(event.currentTarget.value)); },
1776
+ type: 'range',
1777
+ value: currentHue
1778
+ },
1779
+ outputProps: {
1780
+ 'data-ui-theme-output': true,
1781
+ readOnly: true,
1782
+ value: exportValue
1783
+ },
1784
+ previewStyle,
1785
+ primaryColorProps: {
1786
+ 'data-ui-theme-primary-color': true,
1787
+ onChange: event => { setPrimaryColor(event.currentTarget.value); },
1788
+ type: 'color',
1789
+ value: currentPrimaryColor
1790
+ },
1791
+ rootProps: {
1792
+ 'data-ui-theme-builder': true
1793
+ },
1794
+ secondaryColorProps: {
1795
+ 'data-ui-theme-secondary-color': true,
1796
+ onChange: event => { setSecondaryColor(event.currentTarget.value); },
1797
+ type: 'color',
1798
+ value: currentSecondaryColor
1799
+ },
1800
+ setAccentHue,
1801
+ setExportFormat,
1802
+ setHue,
1803
+ setMode,
1804
+ setPrimaryColor,
1805
+ setScheme,
1806
+ setSecondaryColor
1807
+ };
1808
+ };
1809
+ export const useTooltip = ({ defaultOpen = false, delay = 250, id, onOpenChange, open, } = {}) => {
1810
+ const tooltipId = useSafeId('ui-tooltip', id);
1811
+ const timerRef = useRef(undefined);
1812
+ const [isOpen, setIsOpen] = useControllableState({
1813
+ defaultValue: defaultOpen,
1814
+ onChange: onOpenChange,
1815
+ value: open
1816
+ });
1817
+ const close = useCallback(() => {
1818
+ if (timerRef.current) {
1819
+ clearTimeout(timerRef.current);
1820
+ }
1821
+ setIsOpen(false);
1822
+ }, [setIsOpen]);
1823
+ const scheduleOpen = useCallback((nextDelay) => {
1824
+ if (timerRef.current) {
1825
+ clearTimeout(timerRef.current);
1826
+ }
1827
+ timerRef.current = setTimeout(() => { setIsOpen(true); }, nextDelay);
1828
+ }, [setIsOpen]);
1829
+ useEffect(() => () => {
1830
+ if (timerRef.current) {
1831
+ clearTimeout(timerRef.current);
1832
+ }
1833
+ }, []);
1834
+ return {
1835
+ close,
1836
+ open: isOpen,
1837
+ rootProps: {
1838
+ 'aria-describedby': isOpen ? tooltipId : undefined,
1839
+ 'data-ui-tooltip': true,
1840
+ onFocus: () => { scheduleOpen(0); },
1841
+ onKeyDown: (event) => {
1842
+ if (event.key === 'Escape') {
1843
+ close();
1844
+ }
1845
+ },
1846
+ onMouseEnter: () => { scheduleOpen(delay); },
1847
+ onMouseLeave: close
1848
+ },
1849
+ setOpen: setIsOpen,
1850
+ tooltipProps: {
1851
+ hidden: !isOpen,
1852
+ id: tooltipId,
1853
+ role: 'tooltip'
1854
+ }
1855
+ };
1856
+ };
1857
+ const createToastId = () => {
1858
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1859
+ return `ui-toast-${crypto.randomUUID()}`;
1860
+ }
1861
+ return `ui-toast-${Math.random().toString(36).slice(2)}`;
1862
+ };
1863
+ const getToastVariantClass = (variant) => {
1864
+ if (variant === 'success')
1865
+ return 'ui-toast--success';
1866
+ if (variant === 'warning')
1867
+ return 'ui-toast--warning';
1868
+ if (variant === 'destructive')
1869
+ return 'ui-toast--destructive';
1870
+ return false;
1871
+ };
1872
+ const createToastRecord = (detail, placement) => ({
1873
+ ...detail,
1874
+ id: detail.id ?? createToastId(),
1875
+ open: true,
1876
+ placement: detail.placement ?? placement,
1877
+ title: detail.title ?? 'Notification',
1878
+ variant: detail.variant ?? 'default'
1879
+ });
1880
+ export const ToastProvider = ({ children, maxCount = defaultToastMax, placement = 'bottom-right' }) => {
1881
+ const [toasts, setToasts] = useState([]);
1882
+ const removeToast = useCallback((id) => {
1883
+ setToasts(current => current.filter(toast => toast.id !== id));
1884
+ }, []);
1885
+ const dismiss = useCallback((id) => {
1886
+ setToasts(current => current.map(toast => {
1887
+ if (id && toast.id !== id)
1888
+ return toast;
1889
+ return { ...toast, open: false };
1890
+ }));
1891
+ }, []);
1892
+ const create = useCallback((detail) => {
1893
+ const record = createToastRecord(detail, placement);
1894
+ const stackMax = detail.max ?? maxCount;
1895
+ setToasts(current => {
1896
+ const next = current.some(toast => toast.id === record.id)
1897
+ ? current.map(toast => toast.id === record.id ? record : toast)
1898
+ : [...current, record];
1899
+ const samePlacement = next.filter(toast => toast.placement === record.placement);
1900
+ const staleIds = samePlacement
1901
+ .slice(0, Math.max(0, samePlacement.length - stackMax))
1902
+ .map(toast => toast.id);
1903
+ return next.map(toast => staleIds.includes(toast.id) ? { ...toast, open: false } : toast);
1904
+ });
1905
+ return record.id;
1906
+ }, [maxCount, placement]);
1907
+ const update = useCallback((id, detail) => {
1908
+ setToasts(current => current.map(toast => {
1909
+ if (toast.id !== id)
1910
+ return toast;
1911
+ return {
1912
+ ...toast,
1913
+ ...detail,
1914
+ id,
1915
+ open: true,
1916
+ placement: detail.placement ?? toast.placement,
1917
+ title: detail.title ?? toast.title,
1918
+ variant: detail.variant ?? toast.variant
1919
+ };
1920
+ }));
1921
+ }, []);
1922
+ const api = useMemo(() => ({
1923
+ create,
1924
+ dismiss,
1925
+ toasts,
1926
+ update
1927
+ }), [create, dismiss, toasts, update]);
1928
+ const placements = [...new Set([placement, ...toasts.map(toast => toast.placement)])];
1929
+ return (_jsxs(ToastContext.Provider, { value: api, children: [children, placements.map(item => (_jsx(ToastViewport, { maxCount: maxCount, placement: item, removeToast: removeToast, toasts: toasts.filter(toast => toast.placement === item) }, item)))] }));
1930
+ };
1931
+ export const useToast = () => {
1932
+ const api = useContext(ToastContext);
1933
+ if (!api) {
1934
+ throw new Error('useToast must be used inside a ToastProvider.');
1935
+ }
1936
+ return api;
1937
+ };
1938
+ const ToastViewport = ({ maxCount, placement, removeToast, toasts }) => (_jsx("div", { "aria-atomic": "false", "aria-label": "Notifications", "aria-live": "polite", className: "ui-sonner", "data-placement": placement, "data-ui-sonner": true, "data-ui-toast-max": maxCount, children: toasts.map(toast => (_jsx(ToastItem, { onDismiss: () => { removeToast(toast.id); }, toast: toast }, toast.id))) }));
1939
+ const ToastItem = ({ onDismiss, toast }) => {
1940
+ const toastRef = useRef(null);
1941
+ const timerRef = useRef(undefined);
1942
+ const remainingRef = useRef(toast.duration ?? defaultToastDuration);
1943
+ const startedAtRef = useRef(0);
1944
+ const dismiss = useToast().dismiss;
1945
+ const clearTimer = useCallback(() => {
1946
+ if (timerRef.current) {
1947
+ clearTimeout(timerRef.current);
1948
+ }
1949
+ }, []);
1950
+ const startTimer = useCallback(() => {
1951
+ const remaining = remainingRef.current;
1952
+ clearTimer();
1953
+ if (!Number.isFinite(remaining) || remaining <= 0)
1954
+ return;
1955
+ startedAtRef.current = Date.now();
1956
+ timerRef.current = setTimeout(() => { dismiss(toast.id); }, remaining);
1957
+ }, [clearTimer, dismiss, toast.id]);
1958
+ const pauseTimer = useCallback(() => {
1959
+ clearTimer();
1960
+ remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAtRef.current));
1961
+ }, [clearTimer]);
1962
+ const resumeTimer = useCallback(() => {
1963
+ if (remainingRef.current > 0) {
1964
+ startTimer();
1965
+ }
1966
+ }, [startTimer]);
1967
+ useEffect(() => {
1968
+ remainingRef.current = toast.duration ?? defaultToastDuration;
1969
+ startTimer();
1970
+ return clearTimer;
1971
+ }, [clearTimer, startTimer, toast.duration]);
1972
+ useEffect(() => {
1973
+ if (!toast.open) {
1974
+ const timer = setTimeout(onDismiss, closeDelay);
1975
+ return () => { clearTimeout(timer); };
1976
+ }
1977
+ }, [onDismiss, toast.open]);
1978
+ const action = toast.action;
1979
+ const description = toast.description ?? '';
1980
+ const variantClass = getToastVariantClass(toast.variant);
1981
+ return (_jsxs("aside", { "aria-live": toast.variant === 'destructive' ? 'assertive' : 'polite', className: composeClassName('ui-toast', variantClass), "data-description": description, "data-state": toast.open ? 'open' : 'closed', "data-title": toast.title, "data-ui-toast": true, "data-variant": toast.variant, id: toast.id, onFocus: pauseTimer, onKeyDown: (event) => {
1982
+ if (event.key !== 'Escape')
1983
+ return;
1984
+ event.preventDefault();
1985
+ dismiss(toast.id);
1986
+ }, onMouseEnter: pauseTimer, onMouseLeave: resumeTimer, ref: toastRef, role: toast.variant === 'destructive' ? 'alert' : 'status', children: [_jsxs("div", { className: "ui-toast__body", children: [_jsx("strong", { children: toast.title }), description && _jsx("p", { children: description })] }), action?.label && (_jsx("button", { className: "ui-button ui-button--secondary ui-button--sm ui-toast__action", onClick: (event) => {
1987
+ action.onClick?.(event, toastRef.current);
1988
+ toastRef.current?.dispatchEvent(new CustomEvent(action.event ?? 'ui:toast-action', {
1989
+ bubbles: true,
1990
+ detail: { id: toast.id, value: action.value }
1991
+ }));
1992
+ dismiss(toast.id);
1993
+ }, type: "button", children: action.label })), _jsx("button", { "aria-label": "Dismiss notification", className: "ui-toast__dismiss", onClick: () => { dismiss(toast.id); }, type: "button", children: "Dismiss" })] }));
1994
+ };
1995
+ export const useThemeToggle = (defaultTheme = 'light') => {
1996
+ const [theme, setTheme] = useState(() => {
1997
+ if (typeof window !== 'undefined') {
1998
+ return localStorage.getItem('theme') ??
1999
+ (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
2000
+ }
2001
+ return defaultTheme;
2002
+ });
2003
+ useEffect(() => {
2004
+ const doc = document.documentElement;
2005
+ doc.setAttribute('data-theme', theme);
2006
+ if (theme === 'dark') {
2007
+ doc.classList.add('dark');
2008
+ }
2009
+ else {
2010
+ doc.classList.remove('dark');
2011
+ }
2012
+ localStorage.setItem('theme', theme);
2013
+ window.dispatchEvent(new CustomEvent('theme-change', { detail: theme }));
2014
+ }, [theme]);
2015
+ const toggleTheme = (event) => {
2016
+ const isDark = theme === 'dark';
2017
+ const newTheme = isDark ? 'light' : 'dark';
2018
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2019
+ const isTouchDevice = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
2020
+ if (prefersReducedMotion || isTouchDevice || !event) {
2021
+ setTheme(newTheme);
2022
+ return;
2023
+ }
2024
+ const rect = event.currentTarget.getBoundingClientRect();
2025
+ const x = Math.round(rect.left + rect.width / 2);
2026
+ const y = Math.round(rect.top + rect.height / 2);
2027
+ const maxRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
2028
+ const oldBg = isDark ? 'hsl(277 20% 10%)' : 'hsl(268 20% 98%)';
2029
+ const overlay = document.createElement('div');
2030
+ overlay.setAttribute('aria-hidden', 'true');
2031
+ Object.assign(overlay.style, {
2032
+ position: 'fixed',
2033
+ inset: '0',
2034
+ zIndex: '99999',
2035
+ pointerEvents: 'none',
2036
+ backgroundColor: oldBg,
2037
+ clipPath: `circle(${maxRadius}px at ${x}px ${y}px)`,
2038
+ willChange: 'clip-path',
2039
+ });
2040
+ document.body.appendChild(overlay);
2041
+ // Switch theme behind the overlay
2042
+ setTheme(newTheme);
2043
+ requestAnimationFrame(() => {
2044
+ requestAnimationFrame(() => {
2045
+ overlay.style.transition = 'clip-path 580ms cubic-bezier(0.4, 0, 0.2, 1)';
2046
+ overlay.style.clipPath = `circle(0px at ${x}px ${y}px)`;
2047
+ const done = () => {
2048
+ overlay.remove();
2049
+ };
2050
+ overlay.addEventListener('transitionend', done, { once: true });
2051
+ setTimeout(done, 750);
2052
+ });
2053
+ });
2054
+ };
2055
+ return { theme, toggleTheme, setTheme };
2056
+ };