@svadmin/ui 0.75.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/app.css +383 -22
  2. package/dist/app.theme.css +383 -22
  3. package/dist/component-registry.d.ts +300 -0
  4. package/dist/component-registry.js +2 -0
  5. package/dist/components/AppFooter.svelte +137 -0
  6. package/dist/components/AppFooter.svelte.d.ts +25 -0
  7. package/dist/components/CodeEditor.svelte +145 -0
  8. package/dist/components/CodeEditor.svelte.d.ts +26 -0
  9. package/dist/components/HighlightText.svelte +46 -0
  10. package/dist/components/HighlightText.svelte.d.ts +13 -0
  11. package/dist/components/JsonEditor.svelte +182 -0
  12. package/dist/components/JsonEditor.svelte.d.ts +22 -0
  13. package/dist/components/Layout.svelte +38 -5
  14. package/dist/components/Layout.svelte.d.ts +4 -3
  15. package/dist/components/Marquee.svelte +104 -0
  16. package/dist/components/Marquee.svelte.d.ts +20 -0
  17. package/dist/components/MediaPlayer.svelte +104 -0
  18. package/dist/components/MediaPlayer.svelte.d.ts +21 -0
  19. package/dist/components/PhoneInput.svelte +157 -0
  20. package/dist/components/PhoneInput.svelte.d.ts +26 -0
  21. package/dist/components/QRCode.svelte +88 -0
  22. package/dist/components/QRCode.svelte.d.ts +22 -0
  23. package/dist/components/ResizableGrid.svelte +252 -0
  24. package/dist/components/ResizableGrid.svelte.d.ts +34 -0
  25. package/dist/components/Sidebar.svelte +1 -1
  26. package/dist/components/TopProgressBar.svelte +90 -0
  27. package/dist/components/TopProgressBar.svelte.d.ts +15 -0
  28. package/dist/components/browser.svelte.d.ts +65 -0
  29. package/dist/components/browser.svelte.js +228 -0
  30. package/dist/components/charts/AreaChart.svelte +78 -0
  31. package/dist/components/charts/AreaChart.svelte.d.ts +17 -0
  32. package/dist/components/charts/ScatterChart.svelte +82 -0
  33. package/dist/components/charts/ScatterChart.svelte.d.ts +17 -0
  34. package/dist/components/charts/index.d.ts +2 -0
  35. package/dist/components/charts/index.js +2 -0
  36. package/dist/components/code-editor-presets.d.ts +14 -0
  37. package/dist/components/code-editor-presets.js +14 -0
  38. package/dist/components/content/ContentPageHeader.svelte +17 -3
  39. package/dist/components/content/ContentPageHeader.svelte.d.ts +1 -0
  40. package/dist/components/content/ContentPageShell.svelte +12 -3
  41. package/dist/components/content/ContentPageShell.svelte.d.ts +1 -0
  42. package/dist/components/content/MetricBlock.svelte +1 -1
  43. package/dist/components/highlight.d.ts +16 -0
  44. package/dist/components/highlight.js +68 -0
  45. package/dist/components/media.svelte.d.ts +27 -0
  46. package/dist/components/media.svelte.js +60 -0
  47. package/dist/components/phone-dial-codes.d.ts +19 -0
  48. package/dist/components/phone-dial-codes.js +112 -0
  49. package/dist/index.d.ts +19 -1
  50. package/dist/index.js +14 -1
  51. package/dist/recipes.d.ts +77 -38
  52. package/dist/recipes.js +33 -8
  53. package/package.json +64 -4
@@ -0,0 +1,252 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils.js';
4
+
5
+ export interface GridItem {
6
+ id: string;
7
+ /** Column index (0-based). */
8
+ x: number;
9
+ /** Row index (0-based). */
10
+ y: number;
11
+ /** Width in columns. */
12
+ w: number;
13
+ /** Height in rows. */
14
+ h: number;
15
+ }
16
+
17
+ interface Props {
18
+ items: GridItem[];
19
+ /** Number of columns in the grid. */
20
+ cols?: number;
21
+ /** Height of a single row in pixels. */
22
+ rowHeight?: number;
23
+ /** Gap between cells in pixels. */
24
+ gap?: number;
25
+ minW?: number;
26
+ minH?: number;
27
+ /** Enables move/resize handles. */
28
+ editable?: boolean;
29
+ ariaLabel?: string;
30
+ class?: string;
31
+ /** Renders each cell's content. Falls back to `children`. */
32
+ item?: Snippet<[GridItem]>;
33
+ children?: Snippet;
34
+ onchange?: (items: GridItem[]) => void;
35
+ }
36
+
37
+ let {
38
+ items = $bindable([]),
39
+ cols = 12,
40
+ rowHeight = 80,
41
+ gap = 12,
42
+ minW = 1,
43
+ minH = 1,
44
+ editable = true,
45
+ ariaLabel = 'Grid layout',
46
+ class: className = '',
47
+ item: itemSnippet,
48
+ children,
49
+ onchange,
50
+ }: Props = $props();
51
+
52
+ let container = $state<HTMLElement | null>(null);
53
+
54
+ const rows = $derived(Math.max(...items.map((entry) => entry.y + entry.h), 1));
55
+ const height = $derived(`calc(${rows} * (var(--svadmin-grid-row) + ${gap}px) - ${gap}px)`);
56
+
57
+ const clamp = (value: number, min: number, max: number): number =>
58
+ Math.min(Math.max(value, min), Math.max(min, max));
59
+
60
+ function commit(next: GridItem[]): void {
61
+ items = next;
62
+ onchange?.(next);
63
+ }
64
+
65
+ function move(item: GridItem, dxUnits: number, dyUnits: number): void {
66
+ commit(items.map((entry) => entry.id === item.id
67
+ ? {
68
+ ...entry,
69
+ x: clamp(item.x + dxUnits, 0, cols - item.w),
70
+ y: Math.max(0, item.y + dyUnits),
71
+ }
72
+ : entry));
73
+ }
74
+
75
+ function resize(item: GridItem, dw: number, dh: number): void {
76
+ commit(items.map((entry) => entry.id === item.id
77
+ ? {
78
+ ...entry,
79
+ w: clamp(item.w + dw, minW, cols - item.x),
80
+ h: Math.max(minH, item.h + dh),
81
+ }
82
+ : entry));
83
+ }
84
+
85
+ function startMove(event: PointerEvent, item: GridItem): void {
86
+ if (!editable || event.button !== 0) return;
87
+ event.preventDefault();
88
+
89
+ const handle = event.currentTarget as HTMLElement;
90
+ const rect = container?.getBoundingClientRect();
91
+ const stepX = rect && rect.width > 0 ? (rect.width + gap) / cols : 80;
92
+ const stepY = rowHeight + gap;
93
+ const startX = event.clientX;
94
+ const startY = event.clientY;
95
+ const origin = { x: item.x, y: item.y };
96
+
97
+ const onMove = (moveEvent: PointerEvent): void => {
98
+ move(
99
+ { ...item, x: origin.x, y: origin.y },
100
+ Math.round((moveEvent.clientX - startX) / stepX),
101
+ Math.round((moveEvent.clientY - startY) / stepY),
102
+ );
103
+ };
104
+ onPointerGesture(handle, event.pointerId, onMove);
105
+ }
106
+
107
+ function startResize(event: PointerEvent, item: GridItem): void {
108
+ if (!editable || event.button !== 0) return;
109
+ event.preventDefault();
110
+
111
+ const handle = event.currentTarget as HTMLElement;
112
+ const rect = container?.getBoundingClientRect();
113
+ const stepX = rect && rect.width > 0 ? (rect.width + gap) / cols : 80;
114
+ const stepY = rowHeight + gap;
115
+ const startX = event.clientX;
116
+ const startY = event.clientY;
117
+ const origin = { w: item.w, h: item.h };
118
+
119
+ const onMove = (moveEvent: PointerEvent): void => {
120
+ resize(
121
+ { ...item, w: origin.w, h: origin.h },
122
+ Math.round((moveEvent.clientX - startX) / stepX),
123
+ Math.round((moveEvent.clientY - startY) / stepY),
124
+ );
125
+ };
126
+ onPointerGesture(handle, event.pointerId, onMove);
127
+ }
128
+
129
+ function onPointerGesture(
130
+ handle: HTMLElement,
131
+ pointerId: number,
132
+ onMove: (event: PointerEvent) => void,
133
+ ): void {
134
+ const onUp = (): void => {
135
+ window.removeEventListener('pointermove', onMove);
136
+ window.removeEventListener('pointerup', onUp);
137
+ try {
138
+ handle.releasePointerCapture?.(pointerId);
139
+ } catch {
140
+ // Pointer capture may be unavailable or already released.
141
+ }
142
+ };
143
+ try {
144
+ handle.setPointerCapture?.(pointerId);
145
+ } catch {
146
+ // Pointer capture is optional; window listeners still track the gesture.
147
+ }
148
+ window.addEventListener('pointermove', onMove);
149
+ window.addEventListener('pointerup', onUp);
150
+ }
151
+
152
+ function handleKeydown(event: KeyboardEvent, item: GridItem, mode: 'move' | 'resize'): void {
153
+ if (!editable) return;
154
+ let dx = 0;
155
+ let dy = 0;
156
+ if (event.key === 'ArrowRight') dx = 1;
157
+ else if (event.key === 'ArrowLeft') dx = -1;
158
+ else if (event.key === 'ArrowDown') dy = 1;
159
+ else if (event.key === 'ArrowUp') dy = -1;
160
+ else return;
161
+ event.preventDefault();
162
+ if (mode === 'move') move(item, dx, dy);
163
+ else resize(item, dx, dy);
164
+ }
165
+ </script>
166
+
167
+ <div
168
+ bind:this={container}
169
+ class={cn('svadmin-resizable-grid', className)}
170
+ data-slot="resizable-grid"
171
+ data-editable={editable ? 'true' : 'false'}
172
+ style={`--svadmin-grid-row: ${rowHeight}px; height: ${height};`}
173
+ role="group"
174
+ aria-label={ariaLabel}
175
+ >
176
+ {#each items as entry (entry.id)}
177
+ <div
178
+ class="svadmin-resizable-grid__item"
179
+ data-grid-id={entry.id}
180
+ style={`left: calc(${entry.x} * (100% + ${gap}px) / ${cols}); width: calc(${entry.w} * (100% + ${gap}px) / ${cols} - ${gap}px); top: calc(${entry.y} * (var(--svadmin-grid-row) + ${gap}px)); height: calc(${entry.h} * var(--svadmin-grid-row) + ${Math.max(entry.h - 1, 0) * gap}px);`}
181
+ >
182
+ <div class="svadmin-resizable-grid__content">
183
+ {#if itemSnippet}{@render itemSnippet(entry)}{:else if children}{@render children()}{/if}
184
+ </div>
185
+ {#if editable}
186
+ <button
187
+ type="button"
188
+ class="svadmin-resizable-grid__handle svadmin-resizable-grid__handle--move"
189
+ aria-label={`Move ${entry.id}`}
190
+ onpointerdown={(event) => startMove(event, entry)}
191
+ onkeydown={(event) => handleKeydown(event, entry, 'move')}
192
+ >⠿</button>
193
+ <button
194
+ type="button"
195
+ class="svadmin-resizable-grid__handle svadmin-resizable-grid__handle--resize"
196
+ aria-label={`Resize ${entry.id}`}
197
+ onpointerdown={(event) => startResize(event, entry)}
198
+ onkeydown={(event) => handleKeydown(event, entry, 'resize')}
199
+ ></button>
200
+ {/if}
201
+ </div>
202
+ {/each}
203
+ </div>
204
+
205
+ <style>
206
+ .svadmin-resizable-grid {
207
+ position: relative;
208
+ width: 100%;
209
+ }
210
+
211
+ .svadmin-resizable-grid__item {
212
+ position: absolute;
213
+ display: flex;
214
+ overflow: hidden;
215
+ border: 1px solid var(--border, currentColor);
216
+ border-radius: min(var(--radius, 0.5rem), 0.75rem);
217
+ background: var(--card, var(--background, transparent));
218
+ color: var(--card-foreground, var(--foreground, currentColor));
219
+ }
220
+
221
+ .svadmin-resizable-grid__content { min-width: 0; flex: 1; padding: 0.75rem; }
222
+
223
+ .svadmin-resizable-grid__handle {
224
+ position: absolute;
225
+ border: 0;
226
+ padding: 0;
227
+ background: transparent;
228
+ color: var(--muted-foreground, currentColor);
229
+ cursor: pointer;
230
+ }
231
+ .svadmin-resizable-grid__handle:focus-visible { outline: 2px solid var(--ring, currentColor); outline-offset: -2px; }
232
+
233
+ .svadmin-resizable-grid__handle--move {
234
+ top: 0;
235
+ right: 1.25rem;
236
+ width: 1.25rem;
237
+ height: 1.25rem;
238
+ font-size: 0.75rem;
239
+ line-height: 1;
240
+ cursor: grab;
241
+ }
242
+ .svadmin-resizable-grid__handle--move:active { cursor: grabbing; }
243
+
244
+ .svadmin-resizable-grid__handle--resize {
245
+ right: 0;
246
+ bottom: 0;
247
+ width: 1rem;
248
+ height: 1rem;
249
+ background: linear-gradient(135deg, transparent 45%, var(--border, currentColor) 45%, var(--border, currentColor) 55%, transparent 55%, transparent 70%, var(--border, currentColor) 70%, var(--border, currentColor) 80%, transparent 80%);
250
+ cursor: nwse-resize;
251
+ }
252
+ </style>
@@ -0,0 +1,34 @@
1
+ import type { Snippet } from 'svelte';
2
+ export interface GridItem {
3
+ id: string;
4
+ /** Column index (0-based). */
5
+ x: number;
6
+ /** Row index (0-based). */
7
+ y: number;
8
+ /** Width in columns. */
9
+ w: number;
10
+ /** Height in rows. */
11
+ h: number;
12
+ }
13
+ interface Props {
14
+ items: GridItem[];
15
+ /** Number of columns in the grid. */
16
+ cols?: number;
17
+ /** Height of a single row in pixels. */
18
+ rowHeight?: number;
19
+ /** Gap between cells in pixels. */
20
+ gap?: number;
21
+ minW?: number;
22
+ minH?: number;
23
+ /** Enables move/resize handles. */
24
+ editable?: boolean;
25
+ ariaLabel?: string;
26
+ class?: string;
27
+ /** Renders each cell's content. Falls back to `children`. */
28
+ item?: Snippet<[GridItem]>;
29
+ children?: Snippet;
30
+ onchange?: (items: GridItem[]) => void;
31
+ }
32
+ declare const ResizableGrid: import("svelte").Component<Props, {}, "items">;
33
+ type ResizableGrid = ReturnType<typeof ResizableGrid>;
34
+ export default ResizableGrid;
@@ -427,7 +427,7 @@
427
427
  <span class="svadmin-u-36e579c0b41c svadmin-u-2eba0d65d059">{i18n.t('common.toggleTheme')}</span>
428
428
  <span
429
429
  class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29 svadmin-u-ac204c108886 svadmin-u-3daca9af0861 svadmin-u-823f000e740a svadmin-u-6f8ab8abb126"
430
- style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
430
+ style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#635bff'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#635bff'}"
431
431
  ></span>
432
432
  <!-- The dot previews the user-selected runtime theme swatch; the hex
433
433
  fallback only covers the case where no registered theme matches. -->
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+
4
+ interface Props {
5
+ /** 0–100 progress. Leave undefined for an idle/hidden bar. */
6
+ value?: number | null;
7
+ /** Renders an animated unknown-progress bar regardless of `value`. */
8
+ indeterminate?: boolean;
9
+ /** Bar thickness in pixels. */
10
+ height?: number;
11
+ /** Pins the bar to the top edge of the viewport. */
12
+ fixed?: boolean;
13
+ ariaLabel?: string;
14
+ class?: string;
15
+ }
16
+
17
+ let {
18
+ value,
19
+ indeterminate = false,
20
+ height = 3,
21
+ fixed = true,
22
+ ariaLabel = 'Loading',
23
+ class: className = '',
24
+ }: Props = $props();
25
+
26
+ const visible = $derived(indeterminate || (value !== null && value !== undefined));
27
+ const clamped = $derived(Math.max(0, Math.min(100, value ?? 0)));
28
+ </script>
29
+
30
+ {#if visible}
31
+ <div
32
+ class={cn('svadmin-top-progress', className)}
33
+ data-slot="top-progress"
34
+ data-fixed={fixed ? 'true' : 'false'}
35
+ style={`--svadmin-top-progress-height: ${height}px;`}
36
+ role="progressbar"
37
+ aria-label={ariaLabel}
38
+ aria-valuemin="0"
39
+ aria-valuemax="100"
40
+ aria-valuenow={indeterminate ? undefined : clamped}
41
+ >
42
+ <span
43
+ class="svadmin-top-progress__bar"
44
+ data-indeterminate={indeterminate ? 'true' : 'false'}
45
+ style={indeterminate ? undefined : `transform: scaleX(${clamped / 100});`}
46
+ ></span>
47
+ </div>
48
+ {/if}
49
+
50
+ <style>
51
+ .svadmin-top-progress {
52
+ position: relative;
53
+ width: 100%;
54
+ height: var(--svadmin-top-progress-height, 3px);
55
+ overflow: hidden;
56
+ background: var(--muted, color-mix(in oklch, var(--foreground, currentColor) 12%, transparent));
57
+ }
58
+
59
+ .svadmin-top-progress[data-fixed='true'] {
60
+ position: fixed;
61
+ inset-block-start: 0;
62
+ inset-inline: 0;
63
+ z-index: 9999;
64
+ }
65
+
66
+ .svadmin-top-progress__bar {
67
+ display: block;
68
+ width: 100%;
69
+ height: 100%;
70
+ transform-origin: left center;
71
+ background: var(--primary, currentColor);
72
+ transition: transform 240ms ease-out;
73
+ }
74
+
75
+ .svadmin-top-progress__bar[data-indeterminate='true'] {
76
+ width: 40%;
77
+ transform: translateX(-100%);
78
+ animation: svadmin-top-progress-indeterminate 1.1s ease-in-out infinite;
79
+ }
80
+
81
+ @keyframes svadmin-top-progress-indeterminate {
82
+ 0% { transform: translateX(-100%); }
83
+ 100% { transform: translateX(350%); }
84
+ }
85
+
86
+ @media (prefers-reduced-motion: reduce) {
87
+ .svadmin-top-progress__bar { transition: none; }
88
+ .svadmin-top-progress__bar[data-indeterminate='true'] { animation-duration: 2.2s; }
89
+ }
90
+ </style>
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ /** 0–100 progress. Leave undefined for an idle/hidden bar. */
3
+ value?: number | null;
4
+ /** Renders an animated unknown-progress bar regardless of `value`. */
5
+ indeterminate?: boolean;
6
+ /** Bar thickness in pixels. */
7
+ height?: number;
8
+ /** Pins the bar to the top edge of the viewport. */
9
+ fixed?: boolean;
10
+ ariaLabel?: string;
11
+ class?: string;
12
+ }
13
+ declare const TopProgressBar: import("svelte").Component<Props, {}, "">;
14
+ type TopProgressBar = ReturnType<typeof TopProgressBar>;
15
+ export default TopProgressBar;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Svelte-native browser hooks. Every hook must be called during component
3
+ * initialisation because each one registers a scoped effect; SSR renders the
4
+ * documented fallback and hydration corrects the value.
5
+ */
6
+ export interface ClipboardState {
7
+ /** True for `timeout` ms after a successful copy. */
8
+ readonly copied: boolean;
9
+ /** Resolves to `true` when the text reached the clipboard. */
10
+ copy(text: string): Promise<boolean>;
11
+ }
12
+ export interface ClipboardOptions {
13
+ /** Feedback reset delay in ms. Defaults to 1600. */
14
+ timeout?: number;
15
+ }
16
+ export declare function useClipboard(options?: ClipboardOptions): ClipboardState;
17
+ export interface LocalStorageState<T> {
18
+ readonly value: T;
19
+ set(next: T): void;
20
+ remove(): void;
21
+ }
22
+ /**
23
+ * Reactive `localStorage` value with cross-tab synchronization. Serialization
24
+ * failures fall back to the initial value instead of throwing.
25
+ */
26
+ export declare function useLocalStorage<T>(key: string, initial: T): LocalStorageState<T>;
27
+ export interface DebouncedValue<T> {
28
+ readonly value: T;
29
+ }
30
+ /** Trailing-edge debounce over a reactive getter. */
31
+ export declare function useDebouncedValue<T>(source: () => T, delay?: number): DebouncedValue<T>;
32
+ /**
33
+ * Runs `callback` on an interval. Return `null` (or a non-positive number) from
34
+ * `delay` to pause without unmounting the component.
35
+ */
36
+ export declare function useInterval(callback: () => void, delay: () => number | null): void;
37
+ type EventTargetLike = EventTarget | (() => EventTarget | null) | null;
38
+ /** Adds a listener for the component lifetime, re-binding when `target` changes. */
39
+ export declare function useEventListener<E extends Event = Event>(target: EventTargetLike, type: string, handler: (event: E) => void, options?: AddEventListenerOptions | boolean): void;
40
+ /**
41
+ * Fires when a pointer press happens outside `target`. Uses `composedPath()` so
42
+ * shadow DOM boundaries behave the same as the plain `contains` fallback.
43
+ */
44
+ export declare function useOnClickOutside(target: () => HTMLElement | null, handler: (event: Event) => void, enabled?: () => boolean): void;
45
+ export interface SwipeHandlers {
46
+ onSwipeLeft?: () => void;
47
+ onSwipeRight?: () => void;
48
+ onSwipeUp?: () => void;
49
+ onSwipeDown?: () => void;
50
+ /** Minimum travel in px before a swipe is recognised. Defaults to 30. */
51
+ threshold?: number;
52
+ }
53
+ /**
54
+ * Recognises single-finger/mouse swipes on a target using pointer events.
55
+ * For pinching, rotation, or multi-touch gestures, use the Svelte ecosystem
56
+ * `svelte-gestures` package instead.
57
+ */
58
+ export declare function useSwipe(target: () => HTMLElement | null, handlers: SwipeHandlers): void;
59
+ export interface WindowSize {
60
+ readonly width: number;
61
+ readonly height: number;
62
+ }
63
+ /** Reactive viewport size; reports 0 on the server. */
64
+ export declare function useWindowSize(): WindowSize;
65
+ export {};
@@ -0,0 +1,228 @@
1
+ import { untrack } from 'svelte';
2
+ export function useClipboard(options = {}) {
3
+ const timeout = options.timeout ?? 1600;
4
+ let copied = $state(false);
5
+ let timer = null;
6
+ $effect(() => () => {
7
+ if (timer)
8
+ clearTimeout(timer);
9
+ });
10
+ async function copy(text) {
11
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
12
+ if (!clipboard?.writeText)
13
+ return false;
14
+ try {
15
+ await clipboard.writeText(text);
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ copied = true;
21
+ if (timer)
22
+ clearTimeout(timer);
23
+ timer = setTimeout(() => {
24
+ copied = false;
25
+ }, timeout);
26
+ return true;
27
+ }
28
+ return {
29
+ get copied() {
30
+ return copied;
31
+ },
32
+ copy,
33
+ };
34
+ }
35
+ function readStorage(key, fallback) {
36
+ if (typeof localStorage === 'undefined')
37
+ return fallback;
38
+ try {
39
+ const raw = localStorage.getItem(key);
40
+ if (raw === null)
41
+ return fallback;
42
+ return JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return fallback;
46
+ }
47
+ }
48
+ /**
49
+ * Reactive `localStorage` value with cross-tab synchronization. Serialization
50
+ * failures fall back to the initial value instead of throwing.
51
+ */
52
+ export function useLocalStorage(key, initial) {
53
+ let value = $state(untrack(() => readStorage(key, initial)));
54
+ let skipNextPersist = false;
55
+ $effect(() => {
56
+ const serialized = JSON.stringify(value);
57
+ if (skipNextPersist) {
58
+ skipNextPersist = false;
59
+ return;
60
+ }
61
+ if (typeof localStorage === 'undefined')
62
+ return;
63
+ try {
64
+ localStorage.setItem(key, serialized);
65
+ }
66
+ catch {
67
+ // Storage may be full or blocked; keep the in-memory value.
68
+ }
69
+ });
70
+ useEventListener(typeof window === 'undefined' ? null : window, 'storage', (event) => {
71
+ if (event.key !== key || event.newValue === null)
72
+ return;
73
+ try {
74
+ value = JSON.parse(event.newValue);
75
+ }
76
+ catch {
77
+ // Ignore malformed external writes.
78
+ }
79
+ });
80
+ return {
81
+ get value() {
82
+ return value;
83
+ },
84
+ set(next) {
85
+ value = next;
86
+ },
87
+ remove() {
88
+ if (typeof localStorage !== 'undefined') {
89
+ try {
90
+ localStorage.removeItem(key);
91
+ }
92
+ catch {
93
+ // Ignore storage access failures.
94
+ }
95
+ }
96
+ if (value === initial)
97
+ return;
98
+ skipNextPersist = true;
99
+ value = initial;
100
+ },
101
+ };
102
+ }
103
+ /** Trailing-edge debounce over a reactive getter. */
104
+ export function useDebouncedValue(source, delay = 200) {
105
+ let debounced = $state(untrack(source));
106
+ $effect(() => {
107
+ const next = source();
108
+ const timer = setTimeout(() => {
109
+ debounced = next;
110
+ }, Math.max(0, delay));
111
+ return () => clearTimeout(timer);
112
+ });
113
+ return {
114
+ get value() {
115
+ return debounced;
116
+ },
117
+ };
118
+ }
119
+ /**
120
+ * Runs `callback` on an interval. Return `null` (or a non-positive number) from
121
+ * `delay` to pause without unmounting the component.
122
+ */
123
+ export function useInterval(callback, delay) {
124
+ $effect(() => {
125
+ const ms = delay();
126
+ if (ms === null || !Number.isFinite(ms) || ms <= 0 || typeof window === 'undefined')
127
+ return;
128
+ const id = setInterval(callback, ms);
129
+ return () => clearInterval(id);
130
+ });
131
+ }
132
+ /** Adds a listener for the component lifetime, re-binding when `target` changes. */
133
+ export function useEventListener(target, type, handler, options) {
134
+ $effect(() => {
135
+ const resolved = typeof target === 'function' ? target() : target;
136
+ if (!resolved)
137
+ return;
138
+ const listener = handler;
139
+ resolved.addEventListener(type, listener, options);
140
+ return () => resolved.removeEventListener(type, listener, options);
141
+ });
142
+ }
143
+ /**
144
+ * Fires when a pointer press happens outside `target`. Uses `composedPath()` so
145
+ * shadow DOM boundaries behave the same as the plain `contains` fallback.
146
+ */
147
+ export function useOnClickOutside(target, handler, enabled = () => true) {
148
+ useEventListener(() => (typeof document === 'undefined' ? null : document), 'pointerdown', (event) => {
149
+ if (!enabled())
150
+ return;
151
+ const node = target();
152
+ if (!node)
153
+ return;
154
+ const targetNode = event.target;
155
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
156
+ if (path.includes(node))
157
+ return;
158
+ if (targetNode instanceof Node && node.contains(targetNode))
159
+ return;
160
+ handler(event);
161
+ });
162
+ }
163
+ /**
164
+ * Recognises single-finger/mouse swipes on a target using pointer events.
165
+ * For pinching, rotation, or multi-touch gestures, use the Svelte ecosystem
166
+ * `svelte-gestures` package instead.
167
+ */
168
+ export function useSwipe(target, handlers) {
169
+ const threshold = handlers.threshold ?? 30;
170
+ $effect(() => {
171
+ const node = target();
172
+ if (!node)
173
+ return;
174
+ let start = null;
175
+ const onDown = (event) => {
176
+ start = { x: event.clientX, y: event.clientY };
177
+ };
178
+ const onUp = (event) => {
179
+ if (!start)
180
+ return;
181
+ const dx = event.clientX - start.x;
182
+ const dy = event.clientY - start.y;
183
+ start = null;
184
+ if (Math.abs(dx) < threshold && Math.abs(dy) < threshold)
185
+ return;
186
+ if (Math.abs(dx) >= Math.abs(dy)) {
187
+ if (dx > 0)
188
+ handlers.onSwipeRight?.();
189
+ else
190
+ handlers.onSwipeLeft?.();
191
+ }
192
+ else if (dy > 0) {
193
+ handlers.onSwipeDown?.();
194
+ }
195
+ else {
196
+ handlers.onSwipeUp?.();
197
+ }
198
+ };
199
+ const onCancel = () => {
200
+ start = null;
201
+ };
202
+ node.addEventListener('pointerdown', onDown);
203
+ node.addEventListener('pointerup', onUp);
204
+ node.addEventListener('pointercancel', onCancel);
205
+ return () => {
206
+ node.removeEventListener('pointerdown', onDown);
207
+ node.removeEventListener('pointerup', onUp);
208
+ node.removeEventListener('pointercancel', onCancel);
209
+ };
210
+ });
211
+ }
212
+ /** Reactive viewport size; reports 0 on the server. */
213
+ export function useWindowSize() {
214
+ let width = $state(typeof window === 'undefined' ? 0 : window.innerWidth);
215
+ let height = $state(typeof window === 'undefined' ? 0 : window.innerHeight);
216
+ useEventListener(() => (typeof window === 'undefined' ? null : window), 'resize', () => {
217
+ width = window.innerWidth;
218
+ height = window.innerHeight;
219
+ });
220
+ return {
221
+ get width() {
222
+ return width;
223
+ },
224
+ get height() {
225
+ return height;
226
+ },
227
+ };
228
+ }