@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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/components.d.ts +634 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +842 -0
- package/dist/hooks.d.ts +378 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +2056 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/package.json +71 -0
- package/styles.css +5 -0
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
|
+
};
|