@lyeve-labs/ui-kit 0.35.2 → 0.37.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 +111 -1
- package/dist/components/AuthShell.svelte +25 -2
- package/dist/components/AuthShell.svelte.d.ts +13 -0
- package/dist/components/Button.svelte +11 -2
- package/dist/components/Button.svelte.d.ts +1 -1
- package/dist/components/CopyButton.svelte +26 -8
- package/dist/components/CopyButton.svelte.d.ts +10 -1
- package/dist/components/CopyField.svelte +3 -1
- package/dist/components/FilterChips.svelte +76 -25
- package/dist/components/FilterChips.svelte.d.ts +10 -1
- package/dist/components/NumberInput.svelte +7 -1
- package/dist/components/PageShell.svelte +28 -1
- package/dist/components/PageShell.svelte.d.ts +15 -1
- package/dist/components/SegmentedControl.svelte +8 -2
- package/dist/components/SidebarNav.svelte +8 -1
- package/dist/components/Textarea.svelte +3 -1
- package/dist/components/ThemeToggle.svelte +9 -5
- package/dist/components/dialog/ConfirmDialog.svelte +3 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +9 -1
- package/dist/internal/CopyGlyph.svelte +12 -3
- package/dist/internal/copy.svelte.d.ts +13 -2
- package/dist/internal/copy.svelte.js +13 -3
- package/dist/internal/document-title.d.ts +11 -0
- package/dist/internal/document-title.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,7 +175,7 @@ in `styles.css` beside the palette:
|
|
|
175
175
|
|
|
176
176
|
| Token | Value | For |
|
|
177
177
|
| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
178
|
-
| `--duration-fast` | 120ms | a state change in place: color, opacity, border, shadow, focus
|
|
178
|
+
| `--duration-fast` | 120ms | a state change in place: color, opacity, border, shadow, focus |
|
|
179
179
|
| `--duration-base` | 200ms | movement in place (a knob, a chevron, a collapse) and a small surface arriving (menu, popover, tooltip, toast) |
|
|
180
180
|
| `--duration-slow` | 320ms | a large surface arriving (dialog, drawer) |
|
|
181
181
|
| `--duration-progress` | 500ms | a value moving (a progress bar's width) |
|
|
@@ -236,6 +236,116 @@ the fields the panel actually rendered, re-read when the form reveals more, and
|
|
|
236
236
|
a radio or checkbox group counts as the one question it asks. Name a rung and
|
|
237
237
|
it is kept.
|
|
238
238
|
|
|
239
|
+
## Component notes
|
|
240
|
+
|
|
241
|
+
What a component does beyond its props table.
|
|
242
|
+
|
|
243
|
+
### A filter kept in the URL
|
|
244
|
+
|
|
245
|
+
`SegmentedControl` and `FilterChips` take an `href` on each option. When every
|
|
246
|
+
option carries one, the options render as plain links: the current one carries
|
|
247
|
+
`aria-current="page"`, no change is reported, and the row works before the page
|
|
248
|
+
hydrates. Every option carries an `href` or none does, and a row that mixes the
|
|
249
|
+
two throws.
|
|
250
|
+
|
|
251
|
+
```svelte
|
|
252
|
+
<FilterChips
|
|
253
|
+
label="Status"
|
|
254
|
+
value={status}
|
|
255
|
+
options={[
|
|
256
|
+
{ value: 'all', label: 'All', href: '?status=all' },
|
|
257
|
+
{ value: 'open', label: 'Open', count: 7, href: '?status=open' },
|
|
258
|
+
]}
|
|
259
|
+
/>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Copying
|
|
263
|
+
|
|
264
|
+
`CopyButton` takes `text` for words beside its icon, such as "Copy all codes",
|
|
265
|
+
and the words then name the button. A control the kit does not ship copies
|
|
266
|
+
through the same write and confirms on the same clock with `CopyState`. Create
|
|
267
|
+
one while the component initializes, read `status` in markup, and dispose of it
|
|
268
|
+
when the component unmounts.
|
|
269
|
+
|
|
270
|
+
```svelte
|
|
271
|
+
<script lang="ts">
|
|
272
|
+
import { Button, CopyState } from '@lyeve-labs/ui-kit';
|
|
273
|
+
|
|
274
|
+
let { id }: { id: string } = $props();
|
|
275
|
+
const copy = new CopyState();
|
|
276
|
+
$effect(() => () => copy.dispose());
|
|
277
|
+
</script>
|
|
278
|
+
|
|
279
|
+
<Button variant="ghost" size="sm" onclick={() => copy.copy(id)}>
|
|
280
|
+
{copy.status === 'copied' ? 'Copied' : 'Copy ID'}
|
|
281
|
+
</Button>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`copyToClipboard(text)` is the write alone. It resolves to whether the copy
|
|
285
|
+
worked and never throws. Where the async clipboard is missing, as it is on an
|
|
286
|
+
origin without TLS, it falls back to the legacy copy command.
|
|
287
|
+
|
|
288
|
+
### Page titles
|
|
289
|
+
|
|
290
|
+
`PageShell` renders the document title once an app states its format, in its
|
|
291
|
+
root layout. A page then drops its own title element, which would otherwise
|
|
292
|
+
race the shell's. `documentTitle` gives the tab other words than the heading.
|
|
293
|
+
A format that reads reactive state, such as a tenant's name, follows it. An app
|
|
294
|
+
that states no format gets no title element from the kit.
|
|
295
|
+
|
|
296
|
+
```svelte
|
|
297
|
+
<!-- src/routes/+layout.svelte -->
|
|
298
|
+
<script lang="ts">
|
|
299
|
+
import { setDocumentTitleFormat } from '@lyeve-labs/ui-kit';
|
|
300
|
+
|
|
301
|
+
let { children } = $props();
|
|
302
|
+
setDocumentTitleFormat((title) => `${title} | Acme`);
|
|
303
|
+
</script>
|
|
304
|
+
|
|
305
|
+
{@render children()}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
```svelte
|
|
309
|
+
<PageShell title="Edit entry" documentTitle={entry.name}>...</PageShell>
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### A sign-in page in a tenant's colors
|
|
313
|
+
|
|
314
|
+
`AuthShell` takes a `lockup` snippet that replaces the product mark above the
|
|
315
|
+
heading, and passes `class` and `style` to its outer element. Custom properties
|
|
316
|
+
set there scope to the page, so a tenant's accent repaints the brand tokens for
|
|
317
|
+
everything on it. Inside the `href` link the lockup names the link, so give it
|
|
318
|
+
words or an image with alt text.
|
|
319
|
+
|
|
320
|
+
```svelte
|
|
321
|
+
<AuthShell title="Sign in" href="/" style="--color-brand: {tenant.accent}">
|
|
322
|
+
{#snippet lockup()}
|
|
323
|
+
<span class="inline-flex items-center gap-2 text-xl font-semibold text-fg">
|
|
324
|
+
<img src={tenant.logo} alt="" class="size-9" />
|
|
325
|
+
{tenant.name}
|
|
326
|
+
</span>
|
|
327
|
+
{/snippet}
|
|
328
|
+
<form method="POST">...</form>
|
|
329
|
+
</AuthShell>
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Tone names
|
|
333
|
+
|
|
334
|
+
`StatusTone` is the tone `Alert`, `Banner` and `Stat` take. `AccentTone` adds
|
|
335
|
+
`violet` and is the tone `Badge`, `Tag`, `Indicator` and `Progress` take. An app
|
|
336
|
+
that maps its own states onto tones types the map with them.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import type { StatusTone } from '@lyeve-labs/ui-kit';
|
|
340
|
+
|
|
341
|
+
const JOB_TONE: Record<JobState, StatusTone> = {
|
|
342
|
+
queued: 'neutral',
|
|
343
|
+
running: 'brand',
|
|
344
|
+
failed: 'danger',
|
|
345
|
+
done: 'success',
|
|
346
|
+
};
|
|
347
|
+
```
|
|
348
|
+
|
|
239
349
|
## Local development
|
|
240
350
|
|
|
241
351
|
```bash
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils/cn.js';
|
|
3
4
|
import Card from './Card.svelte';
|
|
4
5
|
import Logo from './Logo.svelte';
|
|
5
6
|
|
|
@@ -13,10 +14,23 @@
|
|
|
13
14
|
href?: string;
|
|
14
15
|
/** `md` is a form. `lg` is a walkthrough with more than one column. */
|
|
15
16
|
width?: Width;
|
|
17
|
+
/**
|
|
18
|
+
* Drawn above the heading in place of the product mark, such as a
|
|
19
|
+
* tenant's logo and name. Inside the `href` link it names that link, so
|
|
20
|
+
* it carries words or an image with alt text.
|
|
21
|
+
*/
|
|
22
|
+
lockup?: Snippet;
|
|
16
23
|
/** Controls at the top end of the column: a theme toggle, a language switch. */
|
|
17
24
|
actions?: Snippet;
|
|
18
25
|
/** Under the card: the one link that leads off the page, such as "Create an account". */
|
|
19
26
|
footer?: Snippet;
|
|
27
|
+
/** Added to the page's outer element. */
|
|
28
|
+
class?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Set on the page's outer element, so custom properties scope to the
|
|
31
|
+
* page: a tenant's accent repaints the brand tokens for everything inside.
|
|
32
|
+
*/
|
|
33
|
+
style?: string;
|
|
20
34
|
children: Snippet;
|
|
21
35
|
}
|
|
22
36
|
|
|
@@ -25,8 +39,11 @@
|
|
|
25
39
|
description = undefined,
|
|
26
40
|
href = undefined,
|
|
27
41
|
width = 'md',
|
|
42
|
+
lockup = undefined,
|
|
28
43
|
actions = undefined,
|
|
29
44
|
footer = undefined,
|
|
45
|
+
class: klass = '',
|
|
46
|
+
style = undefined,
|
|
30
47
|
children,
|
|
31
48
|
}: Props = $props();
|
|
32
49
|
|
|
@@ -42,7 +59,7 @@
|
|
|
42
59
|
on some pages, a theme control on others, and one class that named no
|
|
43
60
|
token at all. Stated once here, the way AppShell states the signed-in frame.
|
|
44
61
|
-->
|
|
45
|
-
<main class=
|
|
62
|
+
<main class={cn('flex min-h-screen items-center justify-center bg-ink px-4 py-10', klass)} {style}>
|
|
46
63
|
<div class="w-full {widths[width]}">
|
|
47
64
|
{#if actions}
|
|
48
65
|
<div class="mb-4 flex justify-end">{@render actions()}</div>
|
|
@@ -54,8 +71,14 @@
|
|
|
54
71
|
{href}
|
|
55
72
|
class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
56
73
|
>
|
|
57
|
-
|
|
74
|
+
{#if lockup}
|
|
75
|
+
{@render lockup()}
|
|
76
|
+
{:else}
|
|
77
|
+
<Logo size="lg" />
|
|
78
|
+
{/if}
|
|
58
79
|
</a>
|
|
80
|
+
{:else if lockup}
|
|
81
|
+
<div class="mb-5 flex justify-center">{@render lockup()}</div>
|
|
59
82
|
{:else}
|
|
60
83
|
<Logo size="lg" class="mb-5" />
|
|
61
84
|
{/if}
|
|
@@ -8,10 +8,23 @@ interface Props {
|
|
|
8
8
|
href?: string;
|
|
9
9
|
/** `md` is a form. `lg` is a walkthrough with more than one column. */
|
|
10
10
|
width?: Width;
|
|
11
|
+
/**
|
|
12
|
+
* Drawn above the heading in place of the product mark, such as a
|
|
13
|
+
* tenant's logo and name. Inside the `href` link it names that link, so
|
|
14
|
+
* it carries words or an image with alt text.
|
|
15
|
+
*/
|
|
16
|
+
lockup?: Snippet;
|
|
11
17
|
/** Controls at the top end of the column: a theme toggle, a language switch. */
|
|
12
18
|
actions?: Snippet;
|
|
13
19
|
/** Under the card: the one link that leads off the page, such as "Create an account". */
|
|
14
20
|
footer?: Snippet;
|
|
21
|
+
/** Added to the page's outer element. */
|
|
22
|
+
class?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Set on the page's outer element, so custom properties scope to the
|
|
25
|
+
* page: a tenant's accent repaints the brand tokens for everything inside.
|
|
26
|
+
*/
|
|
27
|
+
style?: string;
|
|
15
28
|
children: Snippet;
|
|
16
29
|
}
|
|
17
30
|
declare const AuthShell: import("svelte").Component<Props, {}, "">;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { TOUCH_GROW } from '../internal/touch.js';
|
|
8
8
|
|
|
9
9
|
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
|
|
10
|
-
type Size = 'sm' | 'md' | 'lg';
|
|
10
|
+
type Size = 'sm' | 'md' | 'lg' | 'icon';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Attributes forwarded to whichever element renders. Naming them, rather than
|
|
@@ -96,10 +96,18 @@
|
|
|
96
96
|
'border border-line-strong text-fg hover:border-brand hover:text-brand active:bg-brand/10',
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
+
/*
|
|
100
|
+
* `icon` is a square for a button whose only content is an icon: the icon
|
|
101
|
+
* sets the size and the box adds 8px round it, so a 16px icon makes the 32px
|
|
102
|
+
* square the header controls use. Without it, an icon-only control beside
|
|
103
|
+
* ThemeToggle had to copy ThemeToggle's classes, and every copy drifted on
|
|
104
|
+
* its own. Name the button with `aria-label`, which also becomes its hint.
|
|
105
|
+
*/
|
|
99
106
|
const sizes: Record<Size, string> = {
|
|
100
107
|
sm: 'px-3 py-1.5 text-sm gap-1.5 rounded-md',
|
|
101
108
|
md: 'px-4 py-2 text-sm gap-2 rounded-lg',
|
|
102
109
|
lg: 'px-5 py-2.5 text-base gap-2 rounded-lg',
|
|
110
|
+
icon: 'p-2 rounded-lg',
|
|
103
111
|
};
|
|
104
112
|
|
|
105
113
|
const spinnerSize = $derived(size === 'lg' ? 18 : 15);
|
|
@@ -113,7 +121,8 @@
|
|
|
113
121
|
*
|
|
114
122
|
* TOUCH_GROW: under a coarse pointer the button takes the control height,
|
|
115
123
|
* which is 44px there, so a button and the input beside it grow together.
|
|
116
|
-
* A mouse sees 36px, 32px and 44px by size, as before
|
|
124
|
+
* A mouse sees 36px, 32px and 44px by size, as before, and an icon-only
|
|
125
|
+
* button its icon plus 16px.
|
|
117
126
|
*/
|
|
118
127
|
const cls = $derived(
|
|
119
128
|
`inline-flex items-center justify-center font-medium whitespace-nowrap transition-colors ` +
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
3
3
|
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
|
|
4
|
-
type Size = 'sm' | 'md' | 'lg';
|
|
4
|
+
type Size = 'sm' | 'md' | 'lg' | 'icon';
|
|
5
5
|
/**
|
|
6
6
|
* Attributes forwarded to whichever element renders. Naming them, rather than
|
|
7
7
|
* accepting an open index signature, means a prop the component does not
|
|
@@ -6,8 +6,17 @@
|
|
|
6
6
|
interface Props {
|
|
7
7
|
/** The text written to the clipboard. */
|
|
8
8
|
value: string;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The accessible name of a button that shows only its icon. A button with
|
|
11
|
+
* `text` takes its name from that text instead.
|
|
12
|
+
*/
|
|
10
13
|
label?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Words shown beside the icon, such as "Copy all codes". They name the
|
|
16
|
+
* button, so the name a voice-control user speaks is the one on screen.
|
|
17
|
+
* Left unset, the button is the icon alone.
|
|
18
|
+
*/
|
|
19
|
+
text?: string;
|
|
11
20
|
/** Announced and shown after a successful copy. */
|
|
12
21
|
copiedLabel?: string;
|
|
13
22
|
/** Icon size in px. */
|
|
@@ -18,11 +27,20 @@
|
|
|
18
27
|
let {
|
|
19
28
|
value,
|
|
20
29
|
label = 'Copy',
|
|
30
|
+
text = undefined,
|
|
21
31
|
copiedLabel = 'Copied',
|
|
22
32
|
size = 14,
|
|
23
33
|
class: klass = '',
|
|
24
34
|
}: Props = $props();
|
|
25
35
|
|
|
36
|
+
/*
|
|
37
|
+
* The icon alone sits at the faint step beside the text it copies. Words
|
|
38
|
+
* read as an action of their own, so they take the muted step the kit sets
|
|
39
|
+
* other text actions in, and the padding a word needs around it.
|
|
40
|
+
*/
|
|
41
|
+
const ICON_ONLY = `${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`;
|
|
42
|
+
const WITH_TEXT = `${HIT_AREA} inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted outline-none transition-colors hover:text-fg active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand`;
|
|
43
|
+
|
|
26
44
|
const copier = new CopyState();
|
|
27
45
|
|
|
28
46
|
const message = $derived.by(() => {
|
|
@@ -45,18 +63,18 @@
|
|
|
45
63
|
-->
|
|
46
64
|
<span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
|
|
47
65
|
<!--
|
|
48
|
-
The accessible name stays
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
copies.
|
|
66
|
+
The accessible name stays the same through the copied state, whether it
|
|
67
|
+
comes from `label` or from `text`. The live region below is what reports the
|
|
68
|
+
result, and renaming the button as well would announce the same word twice
|
|
69
|
+
and then leave a control called "Copied" that copies.
|
|
52
70
|
-->
|
|
53
71
|
<button
|
|
54
72
|
type="button"
|
|
55
|
-
aria-label={label}
|
|
73
|
+
aria-label={text ? undefined : label}
|
|
56
74
|
onclick={() => copier.copy(value)}
|
|
57
|
-
class=
|
|
75
|
+
class={text ? WITH_TEXT : ICON_ONLY}
|
|
58
76
|
>
|
|
59
|
-
<CopyGlyph copied={copier.status === 'copied'} {size} />
|
|
77
|
+
<CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
|
|
60
78
|
</button>
|
|
61
79
|
|
|
62
80
|
<span
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
interface Props {
|
|
2
2
|
/** The text written to the clipboard. */
|
|
3
3
|
value: string;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The accessible name of a button that shows only its icon. A button with
|
|
6
|
+
* `text` takes its name from that text instead.
|
|
7
|
+
*/
|
|
5
8
|
label?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Words shown beside the icon, such as "Copy all codes". They name the
|
|
11
|
+
* button, so the name a voice-control user speaks is the one on screen.
|
|
12
|
+
* Left unset, the button is the icon alone.
|
|
13
|
+
*/
|
|
14
|
+
text?: string;
|
|
6
15
|
/** Announced and shown after a successful copy. */
|
|
7
16
|
copiedLabel?: string;
|
|
8
17
|
/** Icon size in px. */
|
|
@@ -99,7 +99,9 @@
|
|
|
99
99
|
{value}
|
|
100
100
|
onfocus={(e) => e.currentTarget.select()}
|
|
101
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
|
|
102
|
+
class="h-full min-w-0 flex-1 truncate bg-transparent px-3 text-sm text-fg outline-none {mono
|
|
103
|
+
? 'font-mono'
|
|
104
|
+
: ''}"
|
|
103
105
|
/>
|
|
104
106
|
{@render actions?.()}
|
|
105
107
|
{#if secret}
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
count?: number;
|
|
12
12
|
/** A lucide component before the label. */
|
|
13
13
|
icon?: Component<{ size?: number; class?: string }>;
|
|
14
|
+
/**
|
|
15
|
+
* Renders the chip as a link to this URL instead of a button. Every chip
|
|
16
|
+
* carries one or none does. The modes cannot be mixed in one row. The
|
|
17
|
+
* current chip is still the one whose value matches `value`, and it
|
|
18
|
+
* carries aria-current="page", so a page can keep its filter in the URL
|
|
19
|
+
* and the row works before any script has loaded.
|
|
20
|
+
*/
|
|
21
|
+
href?: string;
|
|
14
22
|
}
|
|
15
23
|
</script>
|
|
16
24
|
|
|
@@ -23,12 +31,16 @@
|
|
|
23
31
|
* scrolls, hiding categories past the edge, or squeezes each label onto two
|
|
24
32
|
* lines inside its segment. Chips wrap instead, so every category is in
|
|
25
33
|
* sight at every width. One is always pressed.
|
|
34
|
+
*
|
|
35
|
+
* With an href on every chip the row is a set of links instead, for a
|
|
36
|
+
* filter the page keeps in its URL.
|
|
26
37
|
*/
|
|
27
38
|
import { cn } from '../utils/cn.js';
|
|
39
|
+
import { safeHref } from '../internal/href.js';
|
|
28
40
|
import { HIT_AREA } from '../internal/touch.js';
|
|
29
41
|
|
|
30
42
|
interface Props {
|
|
31
|
-
/** The pressed chip's value. */
|
|
43
|
+
/** The pressed chip's value. In link mode, the value of the current chip. */
|
|
32
44
|
value: T;
|
|
33
45
|
options: FilterChip<T>[];
|
|
34
46
|
/** Names the group for assistive technology. The chips name themselves. */
|
|
@@ -36,6 +48,7 @@
|
|
|
36
48
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
37
49
|
countLabel?: string;
|
|
38
50
|
class?: string;
|
|
51
|
+
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
39
52
|
onchange?: (value: T) => void;
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -48,39 +61,77 @@
|
|
|
48
61
|
onchange = undefined,
|
|
49
62
|
}: Props = $props();
|
|
50
63
|
|
|
64
|
+
/*
|
|
65
|
+
* Link mode when every chip carries an href. A row that mixes the two is
|
|
66
|
+
* refused outright, for the reason SegmentedControl refuses one: a button
|
|
67
|
+
* beside two links would report a choice that the URL never learns about,
|
|
68
|
+
* and the caller has no way to see that from the screen.
|
|
69
|
+
*/
|
|
70
|
+
const linked = $derived.by(() => {
|
|
71
|
+
const count = options.filter((o) => o.href !== undefined).length;
|
|
72
|
+
if (count !== 0 && count !== options.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`FilterChips: every option carries href or none does, ${count} of ${options.length} did`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return count !== 0;
|
|
78
|
+
});
|
|
79
|
+
|
|
51
80
|
function pick(next: T): void {
|
|
52
81
|
if (next === value) return;
|
|
53
82
|
value = next;
|
|
54
83
|
onchange?.(next);
|
|
55
84
|
}
|
|
85
|
+
|
|
86
|
+
/** One treatment for both modes, so a current link reads as a pressed chip. */
|
|
87
|
+
function chipClass(pressed: boolean): string {
|
|
88
|
+
return cn(
|
|
89
|
+
HIT_AREA,
|
|
90
|
+
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
|
|
91
|
+
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
|
|
92
|
+
pressed
|
|
93
|
+
? 'border-brand bg-brand/10 text-fg'
|
|
94
|
+
: 'border-line text-muted hover:border-line-strong hover:text-fg',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
56
97
|
</script>
|
|
57
98
|
|
|
58
99
|
<div class={cn('flex flex-wrap gap-1.5', klass)} role="group" aria-label={label}>
|
|
59
100
|
{#each options as chip (chip.value)}
|
|
60
101
|
{@const pressed = chip.value === value}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
pressed
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
>
|
|
83
|
-
{/if}
|
|
84
|
-
</button>
|
|
102
|
+
{#if linked}
|
|
103
|
+
<!-- A plain anchor with no click handler, so choosing a chip is a
|
|
104
|
+
navigation the browser performs on its own, before the page has
|
|
105
|
+
hydrated as well as after. -->
|
|
106
|
+
<a
|
|
107
|
+
href={safeHref(chip.href)}
|
|
108
|
+
aria-current={pressed ? 'page' : undefined}
|
|
109
|
+
class={chipClass(pressed)}
|
|
110
|
+
>
|
|
111
|
+
{@render face(chip)}
|
|
112
|
+
</a>
|
|
113
|
+
{:else}
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
aria-pressed={pressed}
|
|
117
|
+
class={chipClass(pressed)}
|
|
118
|
+
onclick={() => pick(chip.value)}
|
|
119
|
+
>
|
|
120
|
+
{@render face(chip)}
|
|
121
|
+
</button>
|
|
122
|
+
{/if}
|
|
85
123
|
{/each}
|
|
86
124
|
</div>
|
|
125
|
+
|
|
126
|
+
{#snippet face(chip: FilterChip<T>)}
|
|
127
|
+
{#if chip.icon}
|
|
128
|
+
{@const Icon = chip.icon}
|
|
129
|
+
<Icon size={12} class="shrink-0" />
|
|
130
|
+
{/if}
|
|
131
|
+
{chip.label}
|
|
132
|
+
{#if chip.count !== undefined}
|
|
133
|
+
<span class="font-mono text-[10px] text-faint"
|
|
134
|
+
>{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
|
|
135
|
+
>
|
|
136
|
+
{/if}
|
|
137
|
+
{/snippet}
|
|
@@ -12,10 +12,18 @@ export interface FilterChip<T extends string = string> {
|
|
|
12
12
|
size?: number;
|
|
13
13
|
class?: string;
|
|
14
14
|
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Renders the chip as a link to this URL instead of a button. Every chip
|
|
17
|
+
* carries one or none does. The modes cannot be mixed in one row. The
|
|
18
|
+
* current chip is still the one whose value matches `value`, and it
|
|
19
|
+
* carries aria-current="page", so a page can keep its filter in the URL
|
|
20
|
+
* and the row works before any script has loaded.
|
|
21
|
+
*/
|
|
22
|
+
href?: string;
|
|
15
23
|
}
|
|
16
24
|
declare function $$render<T extends string>(): {
|
|
17
25
|
props: {
|
|
18
|
-
/** The pressed chip's value. */
|
|
26
|
+
/** The pressed chip's value. In link mode, the value of the current chip. */
|
|
19
27
|
value: T;
|
|
20
28
|
options: FilterChip<T>[];
|
|
21
29
|
/** Names the group for assistive technology. The chips name themselves. */
|
|
@@ -23,6 +31,7 @@ declare function $$render<T extends string>(): {
|
|
|
23
31
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
24
32
|
countLabel?: string;
|
|
25
33
|
class?: string;
|
|
34
|
+
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
26
35
|
onchange?: (value: T) => void;
|
|
27
36
|
};
|
|
28
37
|
exports: {};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
controlBorder,
|
|
8
8
|
describedBy,
|
|
9
9
|
} from '../internal/field.js';
|
|
10
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
value?: number;
|
|
@@ -70,8 +71,13 @@
|
|
|
70
71
|
// line-strong, not line: the stepper buttons and the number between them read
|
|
71
72
|
// as one control, and line sits at 1.25:1, so the caps of that control were
|
|
72
73
|
// the part a low-vision user could not find.
|
|
74
|
+
//
|
|
75
|
+
// Under a finger the control token grows to 44px, and steppers as wide as the
|
|
76
|
+
// token left the number 40px inside a 128px field. They narrow instead and
|
|
77
|
+
// take a hit box of the control size, so the number keeps its room and each
|
|
78
|
+
// stepper is still a 44px target.
|
|
73
79
|
const step_ =
|
|
74
|
-
|
|
80
|
+
`${HIT_AREA} flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted coarse:w-8 ` +
|
|
75
81
|
'transition-colors hover:bg-line hover:text-fg ' +
|
|
76
82
|
'disabled:opacity-50 disabled:cursor-not-allowed';
|
|
77
83
|
</script>
|
|
@@ -13,17 +13,32 @@
|
|
|
13
13
|
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
14
14
|
* `nav` and the rest, and a second `main` changes what a landmark query
|
|
15
15
|
* matches for every assistive technology reading the page.
|
|
16
|
+
*
|
|
17
|
+
* The browser tab follows the page title once the app states a format with
|
|
18
|
+
* setDocumentTitleFormat. Until it does, the shell renders no title element,
|
|
19
|
+
* so a page that renders its own keeps it.
|
|
16
20
|
*/
|
|
17
21
|
import type { Snippet } from 'svelte';
|
|
18
22
|
import { ArrowLeft } from '@lucide/svelte';
|
|
23
|
+
import { getDocumentTitleFormat } from '../internal/document-title.js';
|
|
19
24
|
import { safeHref } from '../internal/href.js';
|
|
20
25
|
import { PAGE_PAD, PAGE_STACK, PAGE_WIDTH, type PageWidth } from '../internal/layout.js';
|
|
21
26
|
import { HIT_AREA } from '../internal/touch.js';
|
|
22
27
|
import PageHeader from './PageHeader.svelte';
|
|
23
28
|
|
|
24
29
|
interface Props {
|
|
25
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The page's only h1, rendered through PageHeader. Also the browser tab's
|
|
32
|
+
* title, once the app states a format.
|
|
33
|
+
*/
|
|
26
34
|
title: string;
|
|
35
|
+
/**
|
|
36
|
+
* What the browser tab shows when it should read differently from
|
|
37
|
+
* `title`, such as a record's name on a page whose heading says what is
|
|
38
|
+
* done to it. Read only once the app states a format with
|
|
39
|
+
* setDocumentTitleFormat.
|
|
40
|
+
*/
|
|
41
|
+
documentTitle?: string;
|
|
27
42
|
description?: string;
|
|
28
43
|
/** Content cap. narrow, default, wide, full. */
|
|
29
44
|
width?: PageWidth;
|
|
@@ -77,6 +92,7 @@
|
|
|
77
92
|
|
|
78
93
|
let {
|
|
79
94
|
title,
|
|
95
|
+
documentTitle = undefined,
|
|
80
96
|
description = undefined,
|
|
81
97
|
width = 'default',
|
|
82
98
|
fill = false,
|
|
@@ -89,6 +105,11 @@
|
|
|
89
105
|
children,
|
|
90
106
|
}: Props = $props();
|
|
91
107
|
|
|
108
|
+
const format = getDocumentTitleFormat();
|
|
109
|
+
|
|
110
|
+
/** The tab's words, or nothing at all while the app has stated no format. */
|
|
111
|
+
const tabTitle = $derived(format === undefined ? undefined : format(documentTitle ?? title));
|
|
112
|
+
|
|
92
113
|
/**
|
|
93
114
|
* A fill page owns the viewport instead of sitting in it: the content takes
|
|
94
115
|
* no cap and reaches the edges, and the height the title row leaves goes to
|
|
@@ -132,6 +153,12 @@
|
|
|
132
153
|
'focus-visible:ring-2 focus-visible:ring-brand';
|
|
133
154
|
</script>
|
|
134
155
|
|
|
156
|
+
<svelte:head>
|
|
157
|
+
{#if tabTitle !== undefined}
|
|
158
|
+
<title>{tabTitle}</title>
|
|
159
|
+
{/if}
|
|
160
|
+
</svelte:head>
|
|
161
|
+
|
|
135
162
|
<div class="{frame} {klass}">
|
|
136
163
|
{#if titleHidden}
|
|
137
164
|
<h1 data-testid="page-title" class="sr-only">{title}</h1>
|
|
@@ -12,12 +12,26 @@
|
|
|
12
12
|
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
13
13
|
* `nav` and the rest, and a second `main` changes what a landmark query
|
|
14
14
|
* matches for every assistive technology reading the page.
|
|
15
|
+
*
|
|
16
|
+
* The browser tab follows the page title once the app states a format with
|
|
17
|
+
* setDocumentTitleFormat. Until it does, the shell renders no title element,
|
|
18
|
+
* so a page that renders its own keeps it.
|
|
15
19
|
*/
|
|
16
20
|
import type { Snippet } from 'svelte';
|
|
17
21
|
import { type PageWidth } from '../internal/layout.js';
|
|
18
22
|
interface Props {
|
|
19
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The page's only h1, rendered through PageHeader. Also the browser tab's
|
|
25
|
+
* title, once the app states a format.
|
|
26
|
+
*/
|
|
20
27
|
title: string;
|
|
28
|
+
/**
|
|
29
|
+
* What the browser tab shows when it should read differently from
|
|
30
|
+
* `title`, such as a record's name on a page whose heading says what is
|
|
31
|
+
* done to it. Read only once the app states a format with
|
|
32
|
+
* setDocumentTitleFormat.
|
|
33
|
+
*/
|
|
34
|
+
documentTitle?: string;
|
|
21
35
|
description?: string;
|
|
22
36
|
/** Content cap. narrow, default, wide, full. */
|
|
23
37
|
width?: PageWidth;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
<script lang="ts" generics="T extends string">
|
|
24
24
|
import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
|
|
25
25
|
import { safeHref } from '../internal/href.js';
|
|
26
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
26
27
|
|
|
27
28
|
type Size = 'sm' | 'md';
|
|
28
29
|
|
|
@@ -64,9 +65,14 @@
|
|
|
64
65
|
* Input in the same row line up. The segments stretch inside it rather than
|
|
65
66
|
* setting their own height, which would add the row's padding and border on
|
|
66
67
|
* top and stand the control two pixels proud of everything beside it.
|
|
68
|
+
*
|
|
69
|
+
* Under a finger the small row grows to the control height, as a small
|
|
70
|
+
* Button does, and each segment keeps its visual inside the track and takes
|
|
71
|
+
* a hit box of the control size. A segment that grew instead would stand
|
|
72
|
+
* the track six pixels proud of the Input beside it.
|
|
67
73
|
*/
|
|
68
74
|
const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
|
|
69
|
-
sm: { row: 'h-8', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
|
|
75
|
+
sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
|
|
70
76
|
md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
|
|
71
77
|
};
|
|
72
78
|
|
|
@@ -196,7 +202,7 @@
|
|
|
196
202
|
{@const selected = option.value === value}
|
|
197
203
|
{@const Icon = option.icon}
|
|
198
204
|
{@const segment =
|
|
199
|
-
|
|
205
|
+
`${HIT_AREA} inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
|
|
200
206
|
SIZES[size].segment +
|
|
201
207
|
(selected
|
|
202
208
|
? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
|
|
@@ -166,7 +166,14 @@
|
|
|
166
166
|
{@render body(node)}
|
|
167
167
|
</span>
|
|
168
168
|
{:else if node.href && branch}
|
|
169
|
-
|
|
169
|
+
<!--
|
|
170
|
+
The rail stacks the disclosure under the link, under a mouse and under
|
|
171
|
+
a finger alike, so the link keeps the rail's width. Side by side the two
|
|
172
|
+
did not fit. Under a finger the pair took 92px and scrolled the nav
|
|
173
|
+
sideways. Under a mouse the link shrank to its own padding with its icon
|
|
174
|
+
under the chevron, and a rail with a gutter scrolled as well.
|
|
175
|
+
-->
|
|
176
|
+
<div class={collapsed ? 'flex flex-col items-start gap-1' : 'flex items-center gap-1'}>
|
|
170
177
|
<a
|
|
171
178
|
href={node.href}
|
|
172
179
|
aria-current={current ? 'page' : undefined}
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
{placeholder}
|
|
77
77
|
aria-invalid={error ? 'true' : undefined}
|
|
78
78
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
79
|
-
class="{CONTROL_MULTILINE} {controlBorder(!!error)} {resize ? 'resize-y' : 'resize-none'} {mono
|
|
79
|
+
class="{CONTROL_MULTILINE} {controlBorder(!!error)} {resize ? 'resize-y' : 'resize-none'} {mono
|
|
80
|
+
? 'font-mono'
|
|
81
|
+
: ''}"
|
|
80
82
|
bind:value
|
|
81
83
|
{oninput}
|
|
82
84
|
{onblur}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* implies the state.
|
|
16
16
|
*/
|
|
17
17
|
import { Monitor, Moon, Sun } from '@lucide/svelte';
|
|
18
|
-
import
|
|
18
|
+
import Button from './Button.svelte';
|
|
19
19
|
import {
|
|
20
20
|
THEME_PREFERENCES,
|
|
21
21
|
getThemePreference,
|
|
@@ -81,13 +81,17 @@
|
|
|
81
81
|
}
|
|
82
82
|
</script>
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
<!-- The kit's icon-only Button, so a control set beside this one takes the
|
|
85
|
+
same box by naming the same size, and the name shows on hover as well as
|
|
86
|
+
to a screen reader. -->
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
size="icon"
|
|
86
90
|
onclick={advance}
|
|
87
91
|
data-print="hide"
|
|
88
92
|
data-theme-preference={preference}
|
|
89
93
|
aria-label="Theme: {LABEL[preference]}. Switch to {LABEL[upcoming]}."
|
|
90
|
-
class=
|
|
94
|
+
class={klass}
|
|
91
95
|
>
|
|
92
96
|
<!-- The icon shows the state the control is in, not the one it moves to. It
|
|
93
97
|
showed the destination while the label named the same destination, which
|
|
@@ -99,4 +103,4 @@
|
|
|
99
103
|
{:else}
|
|
100
104
|
<Sun size={16} aria-hidden="true" />
|
|
101
105
|
{/if}
|
|
102
|
-
</
|
|
106
|
+
</Button>
|
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
);
|
|
45
45
|
// An identifier the message refers to. Set apart in monospace on its own
|
|
46
46
|
// line rather than inside the sentence, where a UUID wraps mid-string.
|
|
47
|
-
let detail = $derived(
|
|
47
|
+
let detail = $derived(
|
|
48
|
+
typeof entry.meta?.confirmDetail === 'string' ? entry.meta.confirmDetail : '',
|
|
49
|
+
);
|
|
48
50
|
|
|
49
51
|
async function handleConfirm() {
|
|
50
52
|
if (loading) return;
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type { CellFit } from './components/Table.svelte';
|
|
|
25
25
|
export type { SidebarState } from './components/AppShell.svelte';
|
|
26
26
|
export type { PageWidth, SectionVariant } from './internal/layout.js';
|
|
27
27
|
export type { TreeNode, TriState } from './internal/tree.js';
|
|
28
|
+
export { setDocumentTitleFormat, type DocumentTitleFormat } from './internal/document-title.js';
|
|
28
29
|
export { default as Button } from './components/Button.svelte';
|
|
29
30
|
export { default as ButtonGroup } from './components/ButtonGroup.svelte';
|
|
30
31
|
export { default as Input } from './components/Input.svelte';
|
|
@@ -79,10 +80,12 @@ export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
|
|
|
79
80
|
export { default as PluginCart } from './components/PluginCart.svelte';
|
|
80
81
|
export type { QuickPick } from './components/PluginCart.svelte';
|
|
81
82
|
export { billedCount, formatCents, ladderRows, ladderState, type BillingPeriod, type CartCatalog, type CartPlugin, type CartQuote, type CartQuoteItem, type DiscountBracket, type LadderState, } from './internal/pricing.js';
|
|
83
|
+
export type { AccentTone, StatusTone } from './internal/tone.js';
|
|
82
84
|
export { default as Stat } from './components/Stat.svelte';
|
|
83
85
|
export { default as Kbd } from './components/Kbd.svelte';
|
|
84
86
|
export { default as CopyButton } from './components/CopyButton.svelte';
|
|
85
87
|
export { default as CopyField } from './components/CopyField.svelte';
|
|
88
|
+
export { CopyState, copyToClipboard, type CopyStatus } from './internal/copy.svelte.js';
|
|
86
89
|
export { default as Avatar } from './components/Avatar.svelte';
|
|
87
90
|
export { default as AvatarGroup } from './components/AvatarGroup.svelte';
|
|
88
91
|
export { default as Logo } from './components/Logo.svelte';
|
|
@@ -100,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
100
103
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
101
104
|
export * as motion from './motion.js';
|
|
102
105
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
103
|
-
export declare const VERSION = "0.
|
|
106
|
+
export declare const VERSION = "0.37.0";
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,10 @@ export { default as Table } from './components/Table.svelte';
|
|
|
22
22
|
export { default as DescriptionList } from './components/DescriptionList.svelte';
|
|
23
23
|
export { default as Toolbar } from './components/Toolbar.svelte';
|
|
24
24
|
export { default as TreeView } from './components/TreeView.svelte';
|
|
25
|
+
// Stated once in an app's root layout, the format turns every PageShell's
|
|
26
|
+
// title into the browser tab's. An app that never states one gets no title
|
|
27
|
+
// element from the kit.
|
|
28
|
+
export { setDocumentTitleFormat } from './internal/document-title.js';
|
|
25
29
|
// ── Forms & inputs ─────────────────────────────────────────────────────────
|
|
26
30
|
export { default as Button } from './components/Button.svelte';
|
|
27
31
|
export { default as ButtonGroup } from './components/ButtonGroup.svelte';
|
|
@@ -79,6 +83,10 @@ export { default as Stat } from './components/Stat.svelte';
|
|
|
79
83
|
export { default as Kbd } from './components/Kbd.svelte';
|
|
80
84
|
export { default as CopyButton } from './components/CopyButton.svelte';
|
|
81
85
|
export { default as CopyField } from './components/CopyField.svelte';
|
|
86
|
+
// The clipboard write and the confirmation both copy controls run on, for a
|
|
87
|
+
// control the kit does not ship. A menu item that copies then falls back the
|
|
88
|
+
// same way on an insecure origin and confirms on the same clock.
|
|
89
|
+
export { CopyState, copyToClipboard } from './internal/copy.svelte.js';
|
|
82
90
|
// ── Media ──────────────────────────────────────────────────────────────────
|
|
83
91
|
export { default as Avatar } from './components/Avatar.svelte';
|
|
84
92
|
export { default as AvatarGroup } from './components/AvatarGroup.svelte';
|
|
@@ -108,4 +116,4 @@ export * as motion from './motion.js';
|
|
|
108
116
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
109
117
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
110
118
|
// this line. The build and the test suite fail when the two disagree.
|
|
111
|
-
export const VERSION = '0.
|
|
119
|
+
export const VERSION = '0.37.0';
|
|
@@ -20,10 +20,19 @@
|
|
|
20
20
|
const HIDDEN = 'scale-50 opacity-0 duration-fast ease-exit';
|
|
21
21
|
</script>
|
|
22
22
|
|
|
23
|
-
<span
|
|
24
|
-
|
|
23
|
+
<span
|
|
24
|
+
class="grid place-items-center"
|
|
25
|
+
data-copy-state={copied ? 'copied' : 'idle'}
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
>
|
|
28
|
+
<Copy
|
|
29
|
+
{size}
|
|
30
|
+
class="col-start-1 row-start-1 transition-[opacity,transform] {copied ? HIDDEN : SHOWN}"
|
|
31
|
+
/>
|
|
25
32
|
<Check
|
|
26
33
|
{size}
|
|
27
|
-
class="col-start-1 row-start-1 text-success transition-[opacity,transform] {copied
|
|
34
|
+
class="col-start-1 row-start-1 text-success transition-[opacity,transform] {copied
|
|
35
|
+
? SHOWN
|
|
36
|
+
: HIDDEN}"
|
|
28
37
|
/>
|
|
29
38
|
</span>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both controls report the same three states on the same clock, so a value
|
|
5
5
|
* copied from a field and one copied from a code block confirm the same way.
|
|
6
|
+
* The package entry point exports the write and the state too, so a control
|
|
7
|
+
* the kit does not ship confirms the same way as well.
|
|
6
8
|
*/
|
|
9
|
+
/** Where the last copy stands: at rest, confirmed, or refused. */
|
|
7
10
|
export type CopyStatus = 'idle' | 'copied' | 'failed';
|
|
8
11
|
/** How long the confirmation holds before the control returns to rest. */
|
|
9
12
|
export declare const COPY_REVERT_MS = 1500;
|
|
@@ -16,9 +19,17 @@ export declare const COPY_FAILED_MESSAGE = "Copy failed";
|
|
|
16
19
|
* writeText rejects while the document is not focused. Either way the value
|
|
17
20
|
* goes through a selected, off-screen textarea and the legacy copy command,
|
|
18
21
|
* which the browser still honors inside the click. Only when that also
|
|
19
|
-
* refuses
|
|
22
|
+
* refuses does it resolve to false. It never throws.
|
|
23
|
+
*/
|
|
24
|
+
export declare function copyToClipboard(value: string): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* A copy and its outcome, on the clock CopyButton and CopyField confirm on.
|
|
27
|
+
*
|
|
28
|
+
* `status` reads `copied` or `failed` for 1.5 seconds after `copy` and `idle`
|
|
29
|
+
* otherwise. It is reactive, so markup that reads it follows it. Create one
|
|
30
|
+
* per control while the component initializes, and call `dispose` when that
|
|
31
|
+
* component unmounts, so the revert timer cannot fire after it.
|
|
20
32
|
*/
|
|
21
|
-
export declare function writeClipboard(value: string): Promise<boolean>;
|
|
22
33
|
export declare class CopyState {
|
|
23
34
|
#private;
|
|
24
35
|
status: CopyStatus;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both controls report the same three states on the same clock, so a value
|
|
5
5
|
* copied from a field and one copied from a code block confirm the same way.
|
|
6
|
+
* The package entry point exports the write and the state too, so a control
|
|
7
|
+
* the kit does not ship confirms the same way as well.
|
|
6
8
|
*/
|
|
7
9
|
/** How long the confirmation holds before the control returns to rest. */
|
|
8
10
|
export const COPY_REVERT_MS = 1500;
|
|
@@ -15,9 +17,9 @@ export const COPY_FAILED_MESSAGE = 'Copy failed';
|
|
|
15
17
|
* writeText rejects while the document is not focused. Either way the value
|
|
16
18
|
* goes through a selected, off-screen textarea and the legacy copy command,
|
|
17
19
|
* which the browser still honors inside the click. Only when that also
|
|
18
|
-
* refuses
|
|
20
|
+
* refuses does it resolve to false. It never throws.
|
|
19
21
|
*/
|
|
20
|
-
export async function
|
|
22
|
+
export async function copyToClipboard(value) {
|
|
21
23
|
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
22
24
|
if (clipboard) {
|
|
23
25
|
try {
|
|
@@ -50,6 +52,14 @@ function legacyCopy(value) {
|
|
|
50
52
|
area.remove();
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* A copy and its outcome, on the clock CopyButton and CopyField confirm on.
|
|
57
|
+
*
|
|
58
|
+
* `status` reads `copied` or `failed` for 1.5 seconds after `copy` and `idle`
|
|
59
|
+
* otherwise. It is reactive, so markup that reads it follows it. Create one
|
|
60
|
+
* per control while the component initializes, and call `dispose` when that
|
|
61
|
+
* component unmounts, so the revert timer cannot fire after it.
|
|
62
|
+
*/
|
|
53
63
|
export class CopyState {
|
|
54
64
|
status = $state('idle');
|
|
55
65
|
#timer;
|
|
@@ -57,7 +67,7 @@ export class CopyState {
|
|
|
57
67
|
// A second press restarts the window. Without this the first press's timer
|
|
58
68
|
// reverts the second copy part way through its own.
|
|
59
69
|
clearTimeout(this.#timer);
|
|
60
|
-
this.status = (await
|
|
70
|
+
this.status = (await copyToClipboard(value)) ? 'copied' : 'failed';
|
|
61
71
|
this.#timer = setTimeout(() => (this.status = 'idle'), COPY_REVERT_MS);
|
|
62
72
|
}
|
|
63
73
|
dispose() {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Turns a page's name into the words its browser tab shows. */
|
|
2
|
+
export type DocumentTitleFormat = (title: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* States the format every PageShell below the calling component renders its
|
|
5
|
+
* document title in. Call it while a layout initializes, the way any context
|
|
6
|
+
* is set. A format that reads reactive state is run again when that state
|
|
7
|
+
* changes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function setDocumentTitleFormat(format: DocumentTitleFormat): void;
|
|
10
|
+
/** The format a layout above stated, or undefined when none did. */
|
|
11
|
+
export declare function getDocumentTitleFormat(): DocumentTitleFormat | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a browser tab shows for a page, stated once for a whole app.
|
|
3
|
+
*
|
|
4
|
+
* A console rendered its own title element on every page, and each page spelled
|
|
5
|
+
* the suffix after the page name for itself. PageShell already holds the page's
|
|
6
|
+
* name, so an app that states the format once in its root layout gets every
|
|
7
|
+
* PageShell page titled the same way. An app that states none gets no title
|
|
8
|
+
* from the kit at all, and its pages keep whatever they render themselves.
|
|
9
|
+
*
|
|
10
|
+
* Context rather than a module variable: one server process renders pages for
|
|
11
|
+
* every visitor, and a format that reads the request (a tenant's name, say)
|
|
12
|
+
* would otherwise leak from one render into the next.
|
|
13
|
+
*/
|
|
14
|
+
import { getContext, setContext } from 'svelte';
|
|
15
|
+
const FORMAT = Symbol('document-title-format');
|
|
16
|
+
/**
|
|
17
|
+
* States the format every PageShell below the calling component renders its
|
|
18
|
+
* document title in. Call it while a layout initializes, the way any context
|
|
19
|
+
* is set. A format that reads reactive state is run again when that state
|
|
20
|
+
* changes.
|
|
21
|
+
*/
|
|
22
|
+
export function setDocumentTitleFormat(format) {
|
|
23
|
+
setContext(FORMAT, format);
|
|
24
|
+
}
|
|
25
|
+
/** The format a layout above stated, or undefined when none did. */
|
|
26
|
+
export function getDocumentTitleFormat() {
|
|
27
|
+
return getContext(FORMAT);
|
|
28
|
+
}
|
package/package.json
CHANGED