@lyeve-labs/ui-kit 0.30.0 → 0.31.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/README.md CHANGED
@@ -28,7 +28,7 @@ No config file, no theme provider, no setup ceremony.
28
28
 
29
29
  ## What's in the box
30
30
 
31
- - **68 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
31
+ - **69 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
32
  - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
33
  - **One CSS file:** `@lyeve-labs/ui-kit/styles.css` declares every token; the rest is just Tailwind.
34
34
  - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
@@ -37,13 +37,13 @@ No config file, no theme provider, no setup ceremony.
37
37
  ## Component list
38
38
 
39
39
  <details>
40
- <summary>68 components, organized by purpose</summary>
40
+ <summary>69 components, organized by purpose</summary>
41
41
 
42
42
  **Layout and structure**
43
43
  Card, Panel, AppShell, AuthShell, PageShell, PageHeader, SectionHeading, Divider, Accordion, AccordionItem, Collapsible, Table, DescriptionList, Toolbar, TreeView
44
44
 
45
45
  **Forms and inputs**
46
- Button, ButtonGroup, Input, PasswordInput, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
46
+ Button, ButtonGroup, Input, PasswordInput, CopyField, Textarea, NumberInput, SearchInput, FileInput, Label, Field, FormMessage, SegmentedControl, Select, MultiSelect, Autocomplete, DatePicker, TimePicker, DateTimePicker, Checkbox, CheckboxGroup, Radio, RadioGroup, Toggle
47
47
 
48
48
  **Navigation**
49
49
  Breadcrumb, Tabs, Pagination, StepIndicator, Dropdown, SidebarNav, AccountMenu
@@ -253,7 +253,7 @@ This repo is a single-purpose component library. Nothing but `src/lib/`.
253
253
  ```
254
254
  src/
255
255
  └── lib/ # → published as @lyeve-labs/ui-kit
256
- ├── components/ # 68 .svelte files
256
+ ├── components/ # 69 .svelte files
257
257
  ├── stores/ # toast.svelte.ts
258
258
  ├── styles/ # theme.css (the one stylesheet)
259
259
  ├── utils/ # cn.ts, theme.ts
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- import { Check, Copy } from '@lucide/svelte';
3
2
  import { HIT_AREA } from '../internal/touch.js';
3
+ import CopyGlyph from '../internal/CopyGlyph.svelte';
4
+ import { COPY_FAILED_MESSAGE, CopyState } from '../internal/copy.svelte.js';
4
5
 
5
6
  interface Props {
6
7
  /** The text written to the clipboard. */
@@ -22,68 +23,15 @@
22
23
  class: klass = '',
23
24
  }: Props = $props();
24
25
 
25
- type Status = 'idle' | 'copied' | 'failed';
26
-
27
- /**
28
- * How long the check stays up before the button returns to its resting icon.
29
- *
30
- * Stated once. The copy affordance this replaces was hand-rolled per page, and
31
- * the pages that reverted at all reverted at three different speeds.
32
- */
33
- const REVERT_MS = 1500;
34
-
35
- /**
36
- * What the page says when the write did not happen.
37
- *
38
- * Every one of those hand-rolled copies reported nothing on failure: the user
39
- * pressed the button, the icon did not move, and the value was still only on
40
- * screen with no way to tell whether it had been taken.
41
- */
42
- const FAILED_MESSAGE = 'Copy failed';
43
-
44
- let status = $state<Status>('idle');
45
- let timer: ReturnType<typeof setTimeout> | undefined;
26
+ const copier = new CopyState();
46
27
 
47
28
  const message = $derived.by(() => {
48
- if (status === 'copied') return copiedLabel;
49
- if (status === 'failed') return FAILED_MESSAGE;
29
+ if (copier.status === 'copied') return copiedLabel;
30
+ if (copier.status === 'failed') return COPY_FAILED_MESSAGE;
50
31
  return '';
51
32
  });
52
33
 
53
- /** Writes the value, and reports whether it landed. */
54
- async function write(): Promise<boolean> {
55
- /*
56
- * The clipboard has to be read into a binding and tested. It is undefined
57
- * on an insecure origin, and `navigator.clipboard?.writeText(value)`
58
- * resolves to undefined there rather than throwing, so awaiting it succeeds
59
- * and the button reports a copy that never happened.
60
- */
61
- const clipboard = navigator.clipboard;
62
- if (!clipboard) return false;
63
- try {
64
- // writeText rejects while the document is not focused, which is what a
65
- // press from a background window or an inspector pane produces.
66
- await clipboard.writeText(value);
67
- return true;
68
- } catch {
69
- return false;
70
- }
71
- }
72
-
73
- async function copy() {
74
- // A second press restarts the window. Without this the first press's timer
75
- // reverts the second copy part way through its own.
76
- clearTimeout(timer);
77
- status = (await write()) ? 'copied' : 'failed';
78
- timer = setTimeout(() => (status = 'idle'), REVERT_MS);
79
- }
80
-
81
- /*
82
- * The revert timer outlives the component without this. A table that swaps
83
- * its rows while a check is up leaves the callback assigning to a destroyed
84
- * instance.
85
- */
86
- $effect(() => () => clearTimeout(timer));
34
+ $effect(() => () => copier.dispose());
87
35
  </script>
88
36
 
89
37
  <!--
@@ -97,7 +45,7 @@
97
45
  -->
98
46
  <span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
99
47
  <!--
100
- The accessible name stays `label` through the copied state. The live region
48
+ The accessible name stays `label` through the copied copier. The live region
101
49
  below is what reports the result, and renaming the button as well would
102
50
  announce the same word twice and then leave a control called "Copied" that
103
51
  copies.
@@ -105,23 +53,16 @@
105
53
  <button
106
54
  type="button"
107
55
  aria-label={label}
108
- onclick={copy}
109
- class="{HIT_AREA} inline-flex items-center justify-center rounded-md p-1 outline-none transition-colors active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand {status ===
110
- 'copied'
111
- ? 'text-success'
112
- : 'text-faint hover:text-fg'}"
56
+ onclick={() => copier.copy(value)}
57
+ class="{HIT_AREA} inline-flex items-center justify-center rounded-md p-1 text-faint outline-none transition-colors hover:text-fg active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
113
58
  >
114
- {#if status === 'copied'}
115
- <Check {size} aria-hidden="true" />
116
- {:else}
117
- <Copy {size} aria-hidden="true" />
118
- {/if}
59
+ <CopyGlyph copied={copier.status === 'copied'} {size} />
119
60
  </button>
120
61
 
121
62
  <span
122
63
  role="status"
123
64
  aria-live="polite"
124
65
  aria-atomic="true"
125
- class="text-xs {status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
66
+ class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
126
67
  >
127
68
  </span>
@@ -0,0 +1,146 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A value the reader copies, with the copy control inside the field.
4
+ *
5
+ * Consoles drew a read-only Input and a separate outlined button beside it,
6
+ * so the pair read as two controls of different heights and the button
7
+ * floated on its own at the end of a long row. The control sits inside the
8
+ * field's end the way PasswordInput's reveal does, and confirms by turning
9
+ * its icon into a check in place.
10
+ *
11
+ * Focusing the field selects the whole value, so a reader who would rather
12
+ * copy by keyboard gets all of it and never a part.
13
+ *
14
+ * `secret` masks the value and adds PasswordInput's reveal beside the copy,
15
+ * for a token or a key that should not sit readable on a shared screen. The
16
+ * copy writes the value whether or not it is shown.
17
+ *
18
+ * `actions` adds the caller's own controls in the same row, before the
19
+ * reveal and the copy: a rotate, a regenerate, a link out. They sit in the
20
+ * field's own row, so any number of them fits without covering the value.
21
+ */
22
+ import type { Snippet } from 'svelte';
23
+ import { Eye, EyeOff } from '@lucide/svelte';
24
+ import { HIT_AREA } from '../internal/touch.js';
25
+ import CopyGlyph from '../internal/CopyGlyph.svelte';
26
+ import { COPY_FAILED_MESSAGE, CopyState } from '../internal/copy.svelte.js';
27
+ import {
28
+ FIELD_ERROR,
29
+ FIELD_HINT,
30
+ FIELD_LABEL,
31
+ FIELD_WRAP,
32
+ describedBy,
33
+ } from '../internal/field.js';
34
+
35
+ interface Props {
36
+ /** The text shown and written to the clipboard. */
37
+ value: string;
38
+ id?: string;
39
+ label?: string;
40
+ labelHidden?: boolean;
41
+ hint?: string;
42
+ /** Monospace, for keys, secrets, hashes and addresses. On by default. */
43
+ mono?: boolean;
44
+ /** Mask the value and offer a control that reveals it. */
45
+ secret?: boolean;
46
+ /** Extra controls inside the field's end, before the reveal and the copy. */
47
+ actions?: Snippet;
48
+ /** The copy control's accessible name. Defaults to "Copy" and the label. */
49
+ copyLabel?: string;
50
+ /** Announced after a successful copy. */
51
+ copiedLabel?: string;
52
+ class?: string;
53
+ }
54
+
55
+ let {
56
+ value,
57
+ id = undefined,
58
+ label = undefined,
59
+ labelHidden = false,
60
+ hint = undefined,
61
+ mono = true,
62
+ secret = false,
63
+ actions = undefined,
64
+ copyLabel = undefined,
65
+ copiedLabel = 'Copied',
66
+ class: klass = '',
67
+ }: Props = $props();
68
+
69
+ const uid = $props.id();
70
+ const fieldId = $derived(id ?? uid);
71
+ const buttonName = $derived(copyLabel ?? (label ? `Copy ${label.toLowerCase()}` : 'Copy'));
72
+
73
+ const copier = new CopyState();
74
+ let revealed = $state(false);
75
+ const masked = $derived(secret && !revealed);
76
+ const failed = $derived(copier.status === 'failed');
77
+
78
+ $effect(() => () => copier.dispose());
79
+ </script>
80
+
81
+ <div data-field class="{FIELD_WRAP} {klass}">
82
+ {#if label}
83
+ <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">{label}</label>
84
+ {/if}
85
+
86
+ <!-- The frame is the control: the value and its buttons sit in one row
87
+ inside it, so the caller's actions, the reveal and the copy all fit
88
+ without the value running under them and without measuring anything.
89
+ The frame takes the focus ring and the brand border from the input
90
+ inside it; a focused button draws its own ring and not the frame's. -->
91
+ <div
92
+ class="flex h-control w-full items-center gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand"
93
+ >
94
+ <input
95
+ id={fieldId}
96
+ type={masked ? 'password' : 'text'}
97
+ autocomplete="off"
98
+ readonly
99
+ {value}
100
+ onfocus={(e) => e.currentTarget.select()}
101
+ aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
102
+ class="h-full min-w-0 flex-1 truncate bg-transparent px-3 text-sm text-fg outline-none {mono ? 'font-mono' : ''}"
103
+ />
104
+ {@render actions?.()}
105
+ {#if secret}
106
+ <button
107
+ type="button"
108
+ onclick={() => (revealed = !revealed)}
109
+ aria-pressed={revealed}
110
+ aria-controls={fieldId}
111
+ aria-label={revealed ? 'Hide value' : 'Show value'}
112
+ class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
113
+ >
114
+ {#if revealed}
115
+ <EyeOff size={15} aria-hidden="true" />
116
+ {:else}
117
+ <Eye size={15} aria-hidden="true" />
118
+ {/if}
119
+ </button>
120
+ {/if}
121
+ <button
122
+ type="button"
123
+ aria-label={buttonName}
124
+ onclick={() => copier.copy(value)}
125
+ class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
126
+ >
127
+ <CopyGlyph copied={copier.status === 'copied'} size={15} />
128
+ </button>
129
+ </div>
130
+
131
+ <!-- Mounted empty for the life of the field, so a copy is a change a screen
132
+ reader announces rather than a region that arrives already full. The
133
+ check is the sighted confirmation; only a failure is shown in words. -->
134
+ <p
135
+ role="status"
136
+ aria-live="polite"
137
+ aria-atomic="true"
138
+ id={failed ? `${fieldId}-error` : undefined}
139
+ class={failed ? FIELD_ERROR : 'sr-only'}
140
+ >
141
+ {copier.status === 'copied' ? copiedLabel : failed ? COPY_FAILED_MESSAGE : ''}
142
+ </p>
143
+ {#if hint && !failed}
144
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
145
+ {/if}
146
+ </div>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A value the reader copies, with the copy control inside the field.
3
+ *
4
+ * Consoles drew a read-only Input and a separate outlined button beside it,
5
+ * so the pair read as two controls of different heights and the button
6
+ * floated on its own at the end of a long row. The control sits inside the
7
+ * field's end the way PasswordInput's reveal does, and confirms by turning
8
+ * its icon into a check in place.
9
+ *
10
+ * Focusing the field selects the whole value, so a reader who would rather
11
+ * copy by keyboard gets all of it and never a part.
12
+ *
13
+ * `secret` masks the value and adds PasswordInput's reveal beside the copy,
14
+ * for a token or a key that should not sit readable on a shared screen. The
15
+ * copy writes the value whether or not it is shown.
16
+ *
17
+ * `actions` adds the caller's own controls in the same row, before the
18
+ * reveal and the copy: a rotate, a regenerate, a link out. They sit in the
19
+ * field's own row, so any number of them fits without covering the value.
20
+ */
21
+ import type { Snippet } from 'svelte';
22
+ interface Props {
23
+ /** The text shown and written to the clipboard. */
24
+ value: string;
25
+ id?: string;
26
+ label?: string;
27
+ labelHidden?: boolean;
28
+ hint?: string;
29
+ /** Monospace, for keys, secrets, hashes and addresses. On by default. */
30
+ mono?: boolean;
31
+ /** Mask the value and offer a control that reveals it. */
32
+ secret?: boolean;
33
+ /** Extra controls inside the field's end, before the reveal and the copy. */
34
+ actions?: Snippet;
35
+ /** The copy control's accessible name. Defaults to "Copy" and the label. */
36
+ copyLabel?: string;
37
+ /** Announced after a successful copy. */
38
+ copiedLabel?: string;
39
+ class?: string;
40
+ }
41
+ declare const CopyField: import("svelte").Component<Props, {}, "">;
42
+ type CopyField = ReturnType<typeof CopyField>;
43
+ export default CopyField;
@@ -22,6 +22,7 @@
22
22
  nextThemePreference,
23
23
  setThemePreference,
24
24
  watchSystemTheme,
25
+ watchThemePreference,
25
26
  type ThemePreference,
26
27
  } from '../utils/theme.js';
27
28
 
@@ -51,6 +52,10 @@
51
52
  preference = getThemePreference();
52
53
  });
53
54
 
55
+ // A pick in any other control, a settings picker on the same page or
56
+ // another tab, moves this one with it.
57
+ $effect(() => watchThemePreference((next) => (preference = next)));
58
+
54
59
  /**
55
60
  * A reader on `system` whose OS flips at dusk gets the new palette without
56
61
  * touching anything. Without this the preference is honoured once, at load,
package/dist/index.d.ts CHANGED
@@ -76,6 +76,7 @@ export { default as EmptyState } from './components/EmptyState.svelte';
76
76
  export { default as Stat } from './components/Stat.svelte';
77
77
  export { default as Kbd } from './components/Kbd.svelte';
78
78
  export { default as CopyButton } from './components/CopyButton.svelte';
79
+ export { default as CopyField } from './components/CopyField.svelte';
79
80
  export { default as Avatar } from './components/Avatar.svelte';
80
81
  export { default as AvatarGroup } from './components/AvatarGroup.svelte';
81
82
  export { default as Logo } from './components/Logo.svelte';
@@ -90,7 +91,7 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
90
91
  export type { ConfirmOptions } from './components/dialog/dialog-manager.svelte.js';
91
92
  export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
92
93
  export { cn, type ClassValue } from './utils/cn.js';
93
- export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
94
+ export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
94
95
  export * as motion from './motion.js';
95
96
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
96
- export declare const VERSION = "0.30.0";
97
+ export declare const VERSION = "0.31.0";
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ export { default as EmptyState } from './components/EmptyState.svelte';
74
74
  export { default as Stat } from './components/Stat.svelte';
75
75
  export { default as Kbd } from './components/Kbd.svelte';
76
76
  export { default as CopyButton } from './components/CopyButton.svelte';
77
+ export { default as CopyField } from './components/CopyField.svelte';
77
78
  // ── Media ──────────────────────────────────────────────────────────────────
78
79
  export { default as Avatar } from './components/Avatar.svelte';
79
80
  export { default as AvatarGroup } from './components/AvatarGroup.svelte';
@@ -93,7 +94,7 @@ export { default as ConfirmDialog } from './components/dialog/ConfirmDialog.svel
93
94
  export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, setDialogMeta, getDialogStack, } from './components/dialog/dialog-manager.svelte.js';
94
95
  // ── Utilities ──────────────────────────────────────────────────────────────
95
96
  export { cn } from './utils/cn.js';
96
- export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, } from './utils/theme.js';
97
+ export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, } from './utils/theme.js';
97
98
  // ── Motion ─────────────────────────────────────────────────────────────────
98
99
  // The entrances and exits as Svelte transitions, for a surface a consumer
99
100
  // builds that the kit does not ship: `transition:motion.popover`,
@@ -103,4 +104,4 @@ export * as motion from './motion.js';
103
104
  // ── Version ────────────────────────────────────────────────────────────────
104
105
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
105
106
  // this line; the build and the test suite fail when the two disagree.
106
- export const VERSION = '0.30.0';
107
+ export const VERSION = '0.31.0';
@@ -0,0 +1,29 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The copy icon that turns into a check.
4
+ *
5
+ * Both icons stay mounted in one grid cell and cross over in place, so the
6
+ * control never changes width and the change reads as the same button
7
+ * confirming rather than a new one arriving. The arriving icon eases in on
8
+ * the base rung and the leaving one goes a rung faster, as every exit does.
9
+ */
10
+ import { Check, Copy } from '@lucide/svelte';
11
+
12
+ interface Props {
13
+ copied: boolean;
14
+ size: number;
15
+ }
16
+
17
+ let { copied, size }: Props = $props();
18
+
19
+ const SHOWN = 'scale-100 opacity-100 duration-base ease-enter';
20
+ const HIDDEN = 'scale-50 opacity-0 duration-fast ease-exit';
21
+ </script>
22
+
23
+ <span class="grid place-items-center" data-copy-state={copied ? 'copied' : 'idle'} aria-hidden="true">
24
+ <Copy {size} class="col-start-1 row-start-1 transition-[opacity,transform] {copied ? HIDDEN : SHOWN}" />
25
+ <Check
26
+ {size}
27
+ class="col-start-1 row-start-1 text-success transition-[opacity,transform] {copied ? SHOWN : HIDDEN}"
28
+ />
29
+ </span>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ copied: boolean;
3
+ size: number;
4
+ }
5
+ declare const CopyGlyph: import("svelte").Component<Props, {}, "">;
6
+ type CopyGlyph = ReturnType<typeof CopyGlyph>;
7
+ export default CopyGlyph;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The clipboard write and its outcome, shared by CopyButton and CopyField.
3
+ *
4
+ * Both controls report the same three states on the same clock, so a value
5
+ * copied from a field and one copied from a code block confirm the same way.
6
+ */
7
+ export type CopyStatus = 'idle' | 'copied' | 'failed';
8
+ /** How long the confirmation holds before the control returns to rest. */
9
+ export declare const COPY_REVERT_MS = 1500;
10
+ export declare const COPY_FAILED_MESSAGE = "Copy failed";
11
+ /**
12
+ * Writes to the clipboard and says whether it worked.
13
+ *
14
+ * The async clipboard is absent on an insecure origin, which is where a
15
+ * self-hosted console reached over a LAN address without TLS runs, and
16
+ * writeText rejects while the document is not focused. Either way the value
17
+ * goes through a selected, off-screen textarea and the legacy copy command,
18
+ * which the browser still honours inside the click. Only when that also
19
+ * refuses is it a failure the control reports; it never throws.
20
+ */
21
+ export declare function writeClipboard(value: string): Promise<boolean>;
22
+ export declare class CopyState {
23
+ #private;
24
+ status: CopyStatus;
25
+ copy(value: string): Promise<void>;
26
+ dispose(): void;
27
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The clipboard write and its outcome, shared by CopyButton and CopyField.
3
+ *
4
+ * Both controls report the same three states on the same clock, so a value
5
+ * copied from a field and one copied from a code block confirm the same way.
6
+ */
7
+ /** How long the confirmation holds before the control returns to rest. */
8
+ export const COPY_REVERT_MS = 1500;
9
+ export const COPY_FAILED_MESSAGE = 'Copy failed';
10
+ /**
11
+ * Writes to the clipboard and says whether it worked.
12
+ *
13
+ * The async clipboard is absent on an insecure origin, which is where a
14
+ * self-hosted console reached over a LAN address without TLS runs, and
15
+ * writeText rejects while the document is not focused. Either way the value
16
+ * goes through a selected, off-screen textarea and the legacy copy command,
17
+ * which the browser still honours inside the click. Only when that also
18
+ * refuses is it a failure the control reports; it never throws.
19
+ */
20
+ export async function writeClipboard(value) {
21
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
22
+ if (clipboard) {
23
+ try {
24
+ await clipboard.writeText(value);
25
+ return true;
26
+ }
27
+ catch {
28
+ // The legacy path below.
29
+ }
30
+ }
31
+ return legacyCopy(value);
32
+ }
33
+ function legacyCopy(value) {
34
+ if (typeof document === 'undefined' || typeof document.execCommand !== 'function')
35
+ return false;
36
+ const area = document.createElement('textarea');
37
+ area.value = value;
38
+ area.setAttribute('readonly', '');
39
+ area.style.position = 'fixed';
40
+ area.style.opacity = '0';
41
+ document.body.appendChild(area);
42
+ try {
43
+ area.select();
44
+ return document.execCommand('copy');
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ finally {
50
+ area.remove();
51
+ }
52
+ }
53
+ export class CopyState {
54
+ status = $state('idle');
55
+ #timer;
56
+ async copy(value) {
57
+ // A second press restarts the window. Without this the first press's timer
58
+ // reverts the second copy part way through its own.
59
+ clearTimeout(this.#timer);
60
+ this.status = (await writeClipboard(value)) ? 'copied' : 'failed';
61
+ this.#timer = setTimeout(() => (this.status = 'idle'), COPY_REVERT_MS);
62
+ }
63
+ dispose() {
64
+ clearTimeout(this.#timer);
65
+ }
66
+ }
@@ -89,3 +89,12 @@ export declare function toggleTheme(): Theme;
89
89
  * rest of the session.
90
90
  */
91
91
  export declare function watchSystemTheme(onChange: (theme: Theme) => void): () => void;
92
+ /**
93
+ * Call `onChange` with the new preference whenever any control on this page
94
+ * sets one, or another tab of the same origin stores one. Returns the
95
+ * unsubscribe function, and a no-op one on the server.
96
+ *
97
+ * A control that shows the preference subscribes, so it follows a change made
98
+ * anywhere else rather than keeping the value it read at mount.
99
+ */
100
+ export declare function watchThemePreference(onChange: (preference: ThemePreference) => void): () => void;
@@ -30,6 +30,20 @@
30
30
  * pre-paint script reading the other.
31
31
  */
32
32
  const STORAGE_KEY = 'lyeve-theme';
33
+ /**
34
+ * The window event a preference change announces, so every control on the
35
+ * page shows the same choice. Two controls are routinely on screen at once
36
+ * (a header toggle and a settings picker), and each held its own copy: a
37
+ * pick in one left the other showing the old preference, and a toggle still
38
+ * holding `system` repainted the page to the OS theme at dusk over the
39
+ * reader's explicit choice.
40
+ */
41
+ const CHANGE_EVENT = 'lyeve-theme-change';
42
+ function announce(preference) {
43
+ if (typeof window === 'undefined' || typeof CustomEvent === 'undefined')
44
+ return;
45
+ window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: preference }));
46
+ }
33
47
  /** The order a control cycles through, and the order a picker lists. */
34
48
  export const THEME_PREFERENCES = ['light', 'dark', 'system'];
35
49
  function isPreference(value) {
@@ -119,6 +133,7 @@ export function setThemePreference(preference) {
119
133
  // Storage may be disabled (private mode, quota); the attribute is applied
120
134
  // either way, so the session the reader is in still honours the choice.
121
135
  }
136
+ announce(preference);
122
137
  return theme;
123
138
  }
124
139
  /** Apply a theme and persist the choice to `localStorage`. */
@@ -132,6 +147,7 @@ export function setTheme(theme) {
132
147
  catch {
133
148
  // Storage may be disabled (private mode, quota); fall through silently.
134
149
  }
150
+ announce(theme);
135
151
  }
136
152
  /**
137
153
  * The next preference in `order`, wrapping at the end.
@@ -168,3 +184,30 @@ export function watchSystemTheme(onChange) {
168
184
  query.addEventListener('change', handler);
169
185
  return () => query.removeEventListener('change', handler);
170
186
  }
187
+ /**
188
+ * Call `onChange` with the new preference whenever any control on this page
189
+ * sets one, or another tab of the same origin stores one. Returns the
190
+ * unsubscribe function, and a no-op one on the server.
191
+ *
192
+ * A control that shows the preference subscribes, so it follows a change made
193
+ * anywhere else rather than keeping the value it read at mount.
194
+ */
195
+ export function watchThemePreference(onChange) {
196
+ if (typeof window === 'undefined')
197
+ return () => { };
198
+ const local = (e) => {
199
+ const next = e.detail;
200
+ if (isPreference(next))
201
+ onChange(next);
202
+ };
203
+ const other = (e) => {
204
+ if (e.key === STORAGE_KEY)
205
+ onChange(getThemePreference());
206
+ };
207
+ window.addEventListener(CHANGE_EVENT, local);
208
+ window.addEventListener('storage', other);
209
+ return () => {
210
+ window.removeEventListener(CHANGE_EVENT, local);
211
+ window.removeEventListener('storage', other);
212
+ };
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",