@lyeve-labs/ui-kit 0.36.0 → 0.38.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 +147 -0
- package/dist/components/AuthShell.svelte +25 -2
- package/dist/components/AuthShell.svelte.d.ts +13 -0
- package/dist/components/CopyButton.svelte +58 -15
- package/dist/components/CopyButton.svelte.d.ts +17 -1
- package/dist/components/CopyField.svelte +58 -15
- package/dist/components/CopyField.svelte.d.ts +10 -0
- package/dist/components/EmptyState.svelte +14 -1
- package/dist/components/EmptyState.svelte.d.ts +6 -0
- package/dist/components/FilterChips.svelte +132 -26
- package/dist/components/FilterChips.svelte.d.ts +28 -1
- package/dist/components/Input.svelte +9 -1
- package/dist/components/Input.svelte.d.ts +6 -0
- 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/RadioGroup.svelte +19 -4
- package/dist/components/RadioGroup.svelte.d.ts +11 -0
- package/dist/components/SegmentedControl.svelte +62 -7
- package/dist/components/SegmentedControl.svelte.d.ts +18 -0
- package/dist/components/Select.svelte +9 -1
- package/dist/components/Select.svelte.d.ts +6 -0
- package/dist/components/SidebarNav.svelte +8 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +9 -1
- 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/dist/internal/field.d.ts +8 -0
- package/dist/internal/field.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -236,6 +236,153 @@ 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
|
+
### Hints and errors on a choice row
|
|
263
|
+
|
|
264
|
+
`SegmentedControl` and `FilterChips` take `id`, `hint`, `error` and
|
|
265
|
+
`describedBy`, the field props `Input`, `Select` and `RadioGroup` take. The
|
|
266
|
+
hint shows under the row, and an error replaces it. The group points
|
|
267
|
+
`aria-describedby` at whichever is on screen, then at the ids in
|
|
268
|
+
`describedBy`, so a row inside a `Field` reads that field's message too. A
|
|
269
|
+
segmented row of radios carries `aria-invalid` while it shows an error. ARIA
|
|
270
|
+
does not allow that attribute on a group, so a row of chips or links reports
|
|
271
|
+
its error through the description alone. Both link modes take the same props.
|
|
272
|
+
|
|
273
|
+
`SegmentedControl` draws its own caption from `label`. Inside a `Field`, which
|
|
274
|
+
draws the label already, pass `labelHidden` so the name shows once and the
|
|
275
|
+
group keeps it for a screen reader. `FilterChips` draws no caption, and its
|
|
276
|
+
`label` only names the group.
|
|
277
|
+
|
|
278
|
+
```svelte
|
|
279
|
+
<SegmentedControl
|
|
280
|
+
label="Billing period"
|
|
281
|
+
options={periods}
|
|
282
|
+
bind:value={period}
|
|
283
|
+
hint="Charged at the start of each period."
|
|
284
|
+
error={form?.errors?.period}
|
|
285
|
+
/>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Empty states
|
|
289
|
+
|
|
290
|
+
`EmptyState` renders its title as an h3, for a list inside a section. On a
|
|
291
|
+
page whose only other heading is its h1, pass `level={2}` so the outline does
|
|
292
|
+
not skip a level. The title looks the same at either level.
|
|
293
|
+
|
|
294
|
+
### Copying
|
|
295
|
+
|
|
296
|
+
`CopyButton` takes `text` for words beside its icon, such as "Copy all codes",
|
|
297
|
+
and the words then name the button. `title` adds a tooltip, shown on hover and
|
|
298
|
+
on keyboard focus. It never renames the button, and when it says more than the
|
|
299
|
+
name it becomes the button's description. `CopyField` shows its value on one
|
|
300
|
+
truncated line, and `wrap` shows the whole value across as many lines as it
|
|
301
|
+
takes, breaking anywhere, for a key the reader has to check end to end.
|
|
302
|
+
|
|
303
|
+
A control the kit does not ship copies through the same write and confirms on
|
|
304
|
+
the same clock with `CopyState`. Create one while the component initializes,
|
|
305
|
+
read `status` in markup, and dispose of it when the component unmounts.
|
|
306
|
+
|
|
307
|
+
```svelte
|
|
308
|
+
<script lang="ts">
|
|
309
|
+
import { Button, CopyState } from '@lyeve-labs/ui-kit';
|
|
310
|
+
|
|
311
|
+
let { id }: { id: string } = $props();
|
|
312
|
+
const copy = new CopyState();
|
|
313
|
+
$effect(() => () => copy.dispose());
|
|
314
|
+
</script>
|
|
315
|
+
|
|
316
|
+
<Button variant="ghost" size="sm" onclick={() => copy.copy(id)}>
|
|
317
|
+
{copy.status === 'copied' ? 'Copied' : 'Copy ID'}
|
|
318
|
+
</Button>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`copyToClipboard(text)` is the write alone. It resolves to whether the copy
|
|
322
|
+
worked and never throws. Where the async clipboard is missing, as it is on an
|
|
323
|
+
origin without TLS, it falls back to the legacy copy command.
|
|
324
|
+
|
|
325
|
+
### Page titles
|
|
326
|
+
|
|
327
|
+
`PageShell` renders the document title once an app states its format, in its
|
|
328
|
+
root layout. A page then drops its own title element, which would otherwise
|
|
329
|
+
race the shell's. `documentTitle` gives the tab other words than the heading.
|
|
330
|
+
A format that reads reactive state, such as a tenant's name, follows it. An app
|
|
331
|
+
that states no format gets no title element from the kit.
|
|
332
|
+
|
|
333
|
+
```svelte
|
|
334
|
+
<!-- src/routes/+layout.svelte -->
|
|
335
|
+
<script lang="ts">
|
|
336
|
+
import { setDocumentTitleFormat } from '@lyeve-labs/ui-kit';
|
|
337
|
+
|
|
338
|
+
let { children } = $props();
|
|
339
|
+
setDocumentTitleFormat((title) => `${title} | Acme`);
|
|
340
|
+
</script>
|
|
341
|
+
|
|
342
|
+
{@render children()}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
```svelte
|
|
346
|
+
<PageShell title="Edit entry" documentTitle={entry.name}>...</PageShell>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### A sign-in page in a tenant's colors
|
|
350
|
+
|
|
351
|
+
`AuthShell` takes a `lockup` snippet that replaces the product mark above the
|
|
352
|
+
heading, and passes `class` and `style` to its outer element. Custom properties
|
|
353
|
+
set there scope to the page, so a tenant's accent repaints the brand tokens for
|
|
354
|
+
everything on it. Inside the `href` link the lockup names the link, so give it
|
|
355
|
+
words or an image with alt text.
|
|
356
|
+
|
|
357
|
+
```svelte
|
|
358
|
+
<AuthShell title="Sign in" href="/" style="--color-brand: {tenant.accent}">
|
|
359
|
+
{#snippet lockup()}
|
|
360
|
+
<span class="inline-flex items-center gap-2 text-xl font-semibold text-fg">
|
|
361
|
+
<img src={tenant.logo} alt="" class="size-9" />
|
|
362
|
+
{tenant.name}
|
|
363
|
+
</span>
|
|
364
|
+
{/snippet}
|
|
365
|
+
<form method="POST">...</form>
|
|
366
|
+
</AuthShell>
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Tone names
|
|
370
|
+
|
|
371
|
+
`StatusTone` is the tone `Alert`, `Banner` and `Stat` take. `AccentTone` adds
|
|
372
|
+
`violet` and is the tone `Badge`, `Tag`, `Indicator` and `Progress` take. An app
|
|
373
|
+
that maps its own states onto tones types the map with them.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import type { StatusTone } from '@lyeve-labs/ui-kit';
|
|
377
|
+
|
|
378
|
+
const JOB_TONE: Record<JobState, StatusTone> = {
|
|
379
|
+
queued: 'neutral',
|
|
380
|
+
running: 'brand',
|
|
381
|
+
failed: 'danger',
|
|
382
|
+
done: 'success',
|
|
383
|
+
};
|
|
384
|
+
```
|
|
385
|
+
|
|
239
386
|
## Local development
|
|
240
387
|
|
|
241
388
|
```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, {}, "">;
|
|
@@ -2,12 +2,29 @@
|
|
|
2
2
|
import { HIT_AREA } from '../internal/touch.js';
|
|
3
3
|
import CopyGlyph from '../internal/CopyGlyph.svelte';
|
|
4
4
|
import { COPY_FAILED_MESSAGE, CopyState } from '../internal/copy.svelte.js';
|
|
5
|
+
import Tooltip from './Tooltip.svelte';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
/** The text written to the clipboard. */
|
|
8
9
|
value: string;
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The accessible name of a button that shows only its icon. A button with
|
|
12
|
+
* `text` takes its name from that text instead.
|
|
13
|
+
*/
|
|
10
14
|
label?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Words shown beside the icon, such as "Copy all codes". They name the
|
|
17
|
+
* button, so the name a voice-control user speaks is the one on screen.
|
|
18
|
+
* Left unset, the button is the icon alone.
|
|
19
|
+
*/
|
|
20
|
+
text?: string;
|
|
21
|
+
/**
|
|
22
|
+
* A tooltip on the button, shown on hover and on keyboard focus, such as
|
|
23
|
+
* "Copy the signing key". It never renames the button: the name stays
|
|
24
|
+
* `text`, or `label` on an icon alone. When it says more than the name, it
|
|
25
|
+
* becomes the button's accessible description.
|
|
26
|
+
*/
|
|
27
|
+
title?: string;
|
|
11
28
|
/** Announced and shown after a successful copy. */
|
|
12
29
|
copiedLabel?: string;
|
|
13
30
|
/** Icon size in px. */
|
|
@@ -18,11 +35,21 @@
|
|
|
18
35
|
let {
|
|
19
36
|
value,
|
|
20
37
|
label = 'Copy',
|
|
38
|
+
text = undefined,
|
|
39
|
+
title = undefined,
|
|
21
40
|
copiedLabel = 'Copied',
|
|
22
41
|
size = 14,
|
|
23
42
|
class: klass = '',
|
|
24
43
|
}: Props = $props();
|
|
25
44
|
|
|
45
|
+
/*
|
|
46
|
+
* The icon alone sits at the faint step beside the text it copies. Words
|
|
47
|
+
* read as an action of their own, so they take the muted step the kit sets
|
|
48
|
+
* other text actions in, and the padding a word needs around it.
|
|
49
|
+
*/
|
|
50
|
+
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`;
|
|
51
|
+
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`;
|
|
52
|
+
|
|
26
53
|
const copier = new CopyState();
|
|
27
54
|
|
|
28
55
|
const message = $derived.by(() => {
|
|
@@ -32,6 +59,14 @@
|
|
|
32
59
|
});
|
|
33
60
|
|
|
34
61
|
$effect(() => () => copier.dispose());
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* A tooltip in the same words as the name would be announced twice, as
|
|
65
|
+
* "Copy, Copy", so it is described only when it adds something. Case and
|
|
66
|
+
* outer spaces change nothing a reader hears, so neither counts.
|
|
67
|
+
*/
|
|
68
|
+
const spoken = (words: string) => words.trim().toLowerCase();
|
|
69
|
+
const describes = $derived(spoken(title ?? '') !== spoken(text || label));
|
|
35
70
|
</script>
|
|
36
71
|
|
|
37
72
|
<!--
|
|
@@ -44,20 +79,11 @@
|
|
|
44
79
|
of its icon and a row of them lines up.
|
|
45
80
|
-->
|
|
46
81
|
<span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-->
|
|
53
|
-
<button
|
|
54
|
-
type="button"
|
|
55
|
-
aria-label={label}
|
|
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"
|
|
58
|
-
>
|
|
59
|
-
<CopyGlyph copied={copier.status === 'copied'} {size} />
|
|
60
|
-
</button>
|
|
82
|
+
{#if title}
|
|
83
|
+
<Tooltip text={title} describe={describes}>{@render button()}</Tooltip>
|
|
84
|
+
{:else}
|
|
85
|
+
{@render button()}
|
|
86
|
+
{/if}
|
|
61
87
|
|
|
62
88
|
<span
|
|
63
89
|
role="status"
|
|
@@ -66,3 +92,20 @@
|
|
|
66
92
|
class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
|
|
67
93
|
>
|
|
68
94
|
</span>
|
|
95
|
+
|
|
96
|
+
{#snippet button()}
|
|
97
|
+
<!--
|
|
98
|
+
The accessible name stays the same through the copied state, whether it
|
|
99
|
+
comes from `label` or from `text`. The live region beside it is what
|
|
100
|
+
reports the result, and renaming the button as well would announce the
|
|
101
|
+
same word twice and then leave a control called "Copied" that copies.
|
|
102
|
+
-->
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
aria-label={text ? undefined : label}
|
|
106
|
+
onclick={() => copier.copy(value)}
|
|
107
|
+
class={text ? WITH_TEXT : ICON_ONLY}
|
|
108
|
+
>
|
|
109
|
+
<CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
|
|
110
|
+
</button>
|
|
111
|
+
{/snippet}
|
|
@@ -1,8 +1,24 @@
|
|
|
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;
|
|
15
|
+
/**
|
|
16
|
+
* A tooltip on the button, shown on hover and on keyboard focus, such as
|
|
17
|
+
* "Copy the signing key". It never renames the button: the name stays
|
|
18
|
+
* `text`, or `label` on an icon alone. When it says more than the name, it
|
|
19
|
+
* becomes the button's accessible description.
|
|
20
|
+
*/
|
|
21
|
+
title?: string;
|
|
6
22
|
/** Announced and shown after a successful copy. */
|
|
7
23
|
copiedLabel?: string;
|
|
8
24
|
/** Icon size in px. */
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* `actions` adds the caller's own controls in the same row, before the
|
|
19
19
|
* reveal and the copy: a rotate, a regenerate, a link out. They sit in the
|
|
20
20
|
* field's own row, so any number of them fits without covering the value.
|
|
21
|
+
*
|
|
22
|
+
* `wrap` shows the whole value over as many lines as it takes, for a key or
|
|
23
|
+
* an address the reader has to check end to end rather than recognize.
|
|
21
24
|
*/
|
|
22
25
|
import type { Snippet } from 'svelte';
|
|
23
26
|
import { Eye, EyeOff } from '@lucide/svelte';
|
|
@@ -43,6 +46,13 @@
|
|
|
43
46
|
mono?: boolean;
|
|
44
47
|
/** Mask the value and offer a control that reveals it. */
|
|
45
48
|
secret?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Wraps the whole value across lines instead of showing one truncated
|
|
51
|
+
* line. It breaks anywhere, since a key or a token has no spaces to break
|
|
52
|
+
* at. A masked secret stays on one line, because its dots carry nothing
|
|
53
|
+
* to read, and wraps once revealed.
|
|
54
|
+
*/
|
|
55
|
+
wrap?: boolean;
|
|
46
56
|
/** Extra controls inside the field's end, before the reveal and the copy. */
|
|
47
57
|
actions?: Snippet;
|
|
48
58
|
/** The copy control's accessible name. Defaults to "Copy" and the label. */
|
|
@@ -60,6 +70,7 @@
|
|
|
60
70
|
hint = undefined,
|
|
61
71
|
mono = true,
|
|
62
72
|
secret = false,
|
|
73
|
+
wrap = false,
|
|
63
74
|
actions = undefined,
|
|
64
75
|
copyLabel = undefined,
|
|
65
76
|
copiedLabel = 'Copied',
|
|
@@ -73,6 +84,7 @@
|
|
|
73
84
|
const copier = new CopyState();
|
|
74
85
|
let revealed = $state(false);
|
|
75
86
|
const masked = $derived(secret && !revealed);
|
|
87
|
+
const wrapped = $derived(wrap && !masked);
|
|
76
88
|
const failed = $derived(copier.status === 'failed');
|
|
77
89
|
|
|
78
90
|
$effect(() => () => copier.dispose());
|
|
@@ -86,23 +98,54 @@
|
|
|
86
98
|
<!-- The frame is the control: the value and its buttons sit in one row
|
|
87
99
|
inside it, so the caller's actions, the reveal and the copy all fit
|
|
88
100
|
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. -->
|
|
101
|
+
The frame takes the focus ring and the brand border from the input or
|
|
102
|
+
the textarea inside it. A focused button draws its own ring and not the frame's. -->
|
|
91
103
|
<div
|
|
92
|
-
class="flex
|
|
104
|
+
class="flex w-full gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
|
|
105
|
+
? 'min-h-control items-start py-1 has-[textarea:focus]:border-brand has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-inset has-[textarea:focus-visible]:ring-brand'
|
|
106
|
+
: 'h-control items-center has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand'}"
|
|
93
107
|
>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
{#if wrapped}
|
|
109
|
+
<!-- An input holds one line, so the wrapped value is a read-only
|
|
110
|
+
textarea. A textarea does not grow with its text, so it sits in one
|
|
111
|
+
grid cell with an invisible copy of the value, and the copy sets the
|
|
112
|
+
height. The padding and the frame's together keep one line at the
|
|
113
|
+
control height. -->
|
|
114
|
+
<div class="grid min-w-0 flex-1">
|
|
115
|
+
<span
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
class="invisible col-start-1 row-start-1 px-3 py-1 text-sm break-all whitespace-pre-wrap {mono
|
|
118
|
+
? 'font-mono'
|
|
119
|
+
: ''}">{value}</span
|
|
120
|
+
>
|
|
121
|
+
<textarea
|
|
122
|
+
id={fieldId}
|
|
123
|
+
rows={1}
|
|
124
|
+
autocomplete="off"
|
|
125
|
+
spellcheck="false"
|
|
126
|
+
readonly
|
|
127
|
+
{value}
|
|
128
|
+
onfocus={(e) => e.currentTarget.select()}
|
|
129
|
+
aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
|
|
130
|
+
class="col-start-1 row-start-1 resize-none overflow-hidden bg-transparent px-3 py-1 text-sm break-all whitespace-pre-wrap text-fg outline-none {mono
|
|
131
|
+
? 'font-mono'
|
|
132
|
+
: ''}"
|
|
133
|
+
></textarea>
|
|
134
|
+
</div>
|
|
135
|
+
{:else}
|
|
136
|
+
<input
|
|
137
|
+
id={fieldId}
|
|
138
|
+
type={masked ? 'password' : 'text'}
|
|
139
|
+
autocomplete="off"
|
|
140
|
+
readonly
|
|
141
|
+
{value}
|
|
142
|
+
onfocus={(e) => e.currentTarget.select()}
|
|
143
|
+
aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
|
|
144
|
+
class="h-full min-w-0 flex-1 truncate bg-transparent px-3 text-sm text-fg outline-none {mono
|
|
145
|
+
? 'font-mono'
|
|
146
|
+
: ''}"
|
|
147
|
+
/>
|
|
148
|
+
{/if}
|
|
106
149
|
{@render actions?.()}
|
|
107
150
|
{#if secret}
|
|
108
151
|
<button
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* `actions` adds the caller's own controls in the same row, before the
|
|
18
18
|
* reveal and the copy: a rotate, a regenerate, a link out. They sit in the
|
|
19
19
|
* field's own row, so any number of them fits without covering the value.
|
|
20
|
+
*
|
|
21
|
+
* `wrap` shows the whole value over as many lines as it takes, for a key or
|
|
22
|
+
* an address the reader has to check end to end rather than recognize.
|
|
20
23
|
*/
|
|
21
24
|
import type { Snippet } from 'svelte';
|
|
22
25
|
interface Props {
|
|
@@ -30,6 +33,13 @@ interface Props {
|
|
|
30
33
|
mono?: boolean;
|
|
31
34
|
/** Mask the value and offer a control that reveals it. */
|
|
32
35
|
secret?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Wraps the whole value across lines instead of showing one truncated
|
|
38
|
+
* line. It breaks anywhere, since a key or a token has no spaces to break
|
|
39
|
+
* at. A masked secret stays on one line, because its dots carry nothing
|
|
40
|
+
* to read, and wraps once revealed.
|
|
41
|
+
*/
|
|
42
|
+
wrap?: boolean;
|
|
33
43
|
/** Extra controls inside the field's end, before the reveal and the copy. */
|
|
34
44
|
actions?: Snippet;
|
|
35
45
|
/** The copy control's accessible name. Defaults to "Copy" and the label. */
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
icon?: string;
|
|
6
6
|
iconSnippet?: Snippet;
|
|
7
7
|
title: string;
|
|
8
|
+
/**
|
|
9
|
+
* The title's heading level. 3 by default, for a list inside a section. 2
|
|
10
|
+
* on a page whose only other heading is its title, so a screen reader does
|
|
11
|
+
* not meet a jump from h1 to h3.
|
|
12
|
+
*/
|
|
13
|
+
level?: 2 | 3;
|
|
8
14
|
description?: string;
|
|
9
15
|
class?: string;
|
|
10
16
|
action?: Snippet;
|
|
@@ -15,6 +21,7 @@
|
|
|
15
21
|
icon = undefined,
|
|
16
22
|
iconSnippet = undefined,
|
|
17
23
|
title,
|
|
24
|
+
level = 3,
|
|
18
25
|
description = undefined,
|
|
19
26
|
class: klass = '',
|
|
20
27
|
action,
|
|
@@ -40,7 +47,13 @@
|
|
|
40
47
|
{icon}
|
|
41
48
|
</div>
|
|
42
49
|
{/if}
|
|
43
|
-
|
|
50
|
+
<!-- Two branches rather than one dynamic element, as in SectionHeading:
|
|
51
|
+
a reader of this file can see both headings it can produce. -->
|
|
52
|
+
{#if level === 2}
|
|
53
|
+
<h2 class="text-base font-semibold text-fg">{title}</h2>
|
|
54
|
+
{:else}
|
|
55
|
+
<h3 class="text-base font-semibold text-fg">{title}</h3>
|
|
56
|
+
{/if}
|
|
44
57
|
{#if description}
|
|
45
58
|
<p class="mt-1 max-w-sm text-sm text-muted">{description}</p>
|
|
46
59
|
{/if}
|
|
@@ -3,6 +3,12 @@ interface Props {
|
|
|
3
3
|
icon?: string;
|
|
4
4
|
iconSnippet?: Snippet;
|
|
5
5
|
title: string;
|
|
6
|
+
/**
|
|
7
|
+
* The title's heading level. 3 by default, for a list inside a section. 2
|
|
8
|
+
* on a page whose only other heading is its title, so a screen reader does
|
|
9
|
+
* not meet a jump from h1 to h3.
|
|
10
|
+
*/
|
|
11
|
+
level?: 2 | 3;
|
|
6
12
|
description?: string;
|
|
7
13
|
class?: string;
|
|
8
14
|
action?: Snippet;
|