@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 +4 -4
- package/dist/components/CopyButton.svelte +11 -70
- package/dist/components/CopyField.svelte +146 -0
- package/dist/components/CopyField.svelte.d.ts +43 -0
- package/dist/components/ThemeToggle.svelte +5 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal/CopyGlyph.svelte +29 -0
- package/dist/internal/CopyGlyph.svelte.d.ts +7 -0
- package/dist/internal/copy.svelte.d.ts +27 -0
- package/dist/internal/copy.svelte.js +66 -0
- package/dist/utils/theme.d.ts +9 -0
- package/dist/utils/theme.js +43 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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>
|
|
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/ #
|
|
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
|
-
|
|
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
|
|
29
|
+
if (copier.status === 'copied') return copiedLabel;
|
|
30
|
+
if (copier.status === 'failed') return COPY_FAILED_MESSAGE;
|
|
50
31
|
return '';
|
|
51
32
|
});
|
|
52
33
|
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
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,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
|
+
}
|
package/dist/utils/theme.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/theme.js
CHANGED
|
@@ -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