@lyeve-labs/ui-kit 0.37.0 → 0.38.1
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 +41 -4
- package/dist/components/CopyButton.svelte +36 -11
- package/dist/components/CopyButton.svelte.d.ts +7 -0
- 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/FileInput.svelte +1 -1
- package/dist/components/FilterChips.svelte +56 -1
- package/dist/components/FilterChips.svelte.d.ts +18 -0
- package/dist/components/Input.svelte +9 -1
- package/dist/components/Input.svelte.d.ts +6 -0
- package/dist/components/PageHeader.svelte +6 -2
- package/dist/components/RadioGroup.svelte +19 -4
- package/dist/components/RadioGroup.svelte.d.ts +11 -0
- package/dist/components/SegmentedControl.svelte +54 -5
- 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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/field.d.ts +8 -0
- package/dist/internal/field.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -259,13 +259,50 @@ two throws.
|
|
|
259
259
|
/>
|
|
260
260
|
```
|
|
261
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
|
+
|
|
262
294
|
### Copying
|
|
263
295
|
|
|
264
296
|
`CopyButton` takes `text` for words beside its icon, such as "Copy all codes",
|
|
265
|
-
and the words then name the button.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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.
|
|
269
306
|
|
|
270
307
|
```svelte
|
|
271
308
|
<script lang="ts">
|
|
@@ -2,6 +2,7 @@
|
|
|
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. */
|
|
@@ -17,6 +18,13 @@
|
|
|
17
18
|
* Left unset, the button is the icon alone.
|
|
18
19
|
*/
|
|
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;
|
|
20
28
|
/** Announced and shown after a successful copy. */
|
|
21
29
|
copiedLabel?: string;
|
|
22
30
|
/** Icon size in px. */
|
|
@@ -28,6 +36,7 @@
|
|
|
28
36
|
value,
|
|
29
37
|
label = 'Copy',
|
|
30
38
|
text = undefined,
|
|
39
|
+
title = undefined,
|
|
31
40
|
copiedLabel = 'Copied',
|
|
32
41
|
size = 14,
|
|
33
42
|
class: klass = '',
|
|
@@ -50,6 +59,14 @@
|
|
|
50
59
|
});
|
|
51
60
|
|
|
52
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));
|
|
53
70
|
</script>
|
|
54
71
|
|
|
55
72
|
<!--
|
|
@@ -62,11 +79,26 @@
|
|
|
62
79
|
of its icon and a row of them lines up.
|
|
63
80
|
-->
|
|
64
81
|
<span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
|
|
82
|
+
{#if title}
|
|
83
|
+
<Tooltip text={title} describe={describes}>{@render button()}</Tooltip>
|
|
84
|
+
{:else}
|
|
85
|
+
{@render button()}
|
|
86
|
+
{/if}
|
|
87
|
+
|
|
88
|
+
<span
|
|
89
|
+
role="status"
|
|
90
|
+
aria-live="polite"
|
|
91
|
+
aria-atomic="true"
|
|
92
|
+
class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
|
|
93
|
+
>
|
|
94
|
+
</span>
|
|
95
|
+
|
|
96
|
+
{#snippet button()}
|
|
65
97
|
<!--
|
|
66
98
|
The accessible name stays the same through the copied state, whether it
|
|
67
|
-
comes from `label` or from `text`. The live region
|
|
68
|
-
result, and renaming the button as well would announce the
|
|
69
|
-
and then leave a control called "Copied" that copies.
|
|
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.
|
|
70
102
|
-->
|
|
71
103
|
<button
|
|
72
104
|
type="button"
|
|
@@ -76,11 +108,4 @@
|
|
|
76
108
|
>
|
|
77
109
|
<CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
|
|
78
110
|
</button>
|
|
79
|
-
|
|
80
|
-
<span
|
|
81
|
-
role="status"
|
|
82
|
-
aria-live="polite"
|
|
83
|
-
aria-atomic="true"
|
|
84
|
-
class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
|
|
85
|
-
>
|
|
86
|
-
</span>
|
|
111
|
+
{/snippet}
|
|
@@ -12,6 +12,13 @@ interface Props {
|
|
|
12
12
|
* Left unset, the button is the icon alone.
|
|
13
13
|
*/
|
|
14
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;
|
|
15
22
|
/** Announced and shown after a successful copy. */
|
|
16
23
|
copiedLabel?: string;
|
|
17
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;
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
<div class="text-sm">
|
|
91
91
|
<span class="{error ? 'text-danger' : 'text-brand'} font-medium">Click to upload</span>
|
|
92
92
|
<span class="text-muted"> or drag & drop</span>
|
|
93
|
-
{#if accept}<p class="mt-0.5 text-xs text-faint">{accept}</p>{/if}
|
|
93
|
+
{#if accept}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{accept}</p>{/if}
|
|
94
94
|
</div>
|
|
95
95
|
<input
|
|
96
96
|
type="file"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* filter the page keeps in its URL.
|
|
37
37
|
*/
|
|
38
38
|
import { cn } from '../utils/cn.js';
|
|
39
|
+
import { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
|
|
39
40
|
import { safeHref } from '../internal/href.js';
|
|
40
41
|
import { HIT_AREA } from '../internal/touch.js';
|
|
41
42
|
|
|
@@ -47,6 +48,24 @@
|
|
|
47
48
|
label: string;
|
|
48
49
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
49
50
|
countLabel?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The group's id. The hint and the error take theirs from it. Left unset,
|
|
53
|
+
* the group carries no id and the messages take a generated one.
|
|
54
|
+
*/
|
|
55
|
+
id?: string;
|
|
56
|
+
/** Shown under the row while there is no error. */
|
|
57
|
+
hint?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Replaces the hint while it is set. The group points at it and is not
|
|
60
|
+
* marked invalid, because ARIA refuses that attribute on a group.
|
|
61
|
+
*/
|
|
62
|
+
error?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
65
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
66
|
+
* own hint or error.
|
|
67
|
+
*/
|
|
68
|
+
describedBy?: string;
|
|
50
69
|
class?: string;
|
|
51
70
|
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
52
71
|
onchange?: (value: T) => void;
|
|
@@ -57,6 +76,10 @@
|
|
|
57
76
|
options,
|
|
58
77
|
label,
|
|
59
78
|
countLabel = '',
|
|
79
|
+
id = undefined,
|
|
80
|
+
hint = undefined,
|
|
81
|
+
error = undefined,
|
|
82
|
+
describedBy: callerIds = undefined,
|
|
60
83
|
class: klass = '',
|
|
61
84
|
onchange = undefined,
|
|
62
85
|
}: Props = $props();
|
|
@@ -77,6 +100,15 @@
|
|
|
77
100
|
return count !== 0;
|
|
78
101
|
});
|
|
79
102
|
|
|
103
|
+
/*
|
|
104
|
+
* $props.id() and not Math.random(), so the message ids match between the
|
|
105
|
+
* server render and hydration. The group carries an id only when the caller
|
|
106
|
+
* names one, so a row given none keeps the attributes it had.
|
|
107
|
+
*/
|
|
108
|
+
const uid = $props.id();
|
|
109
|
+
const messageBase = $derived(id ?? uid);
|
|
110
|
+
const describedByIds = $derived(joinIds(describedBy(messageBase, error, hint), callerIds));
|
|
111
|
+
|
|
80
112
|
function pick(next: T): void {
|
|
81
113
|
if (next === value) return;
|
|
82
114
|
value = next;
|
|
@@ -96,7 +128,25 @@
|
|
|
96
128
|
}
|
|
97
129
|
</script>
|
|
98
130
|
|
|
99
|
-
|
|
131
|
+
<!--
|
|
132
|
+
The row is rendered once and the message follows it as a sibling. A column
|
|
133
|
+
wrapped round the two only while a message showed rebuilt the row each time
|
|
134
|
+
an error arrived or cleared, and a chip that held focus dropped it to the
|
|
135
|
+
page. The row carries the field marker while it reports a message, so a
|
|
136
|
+
panel sizing itself to its form counts it as the one field it then is.
|
|
137
|
+
|
|
138
|
+
The row carries no aria-invalid. ARIA does not allow it on a group, or on
|
|
139
|
+
the buttons and links inside one, so an error reaches a reader through
|
|
140
|
+
aria-describedby, which reads the message itself.
|
|
141
|
+
-->
|
|
142
|
+
<div
|
|
143
|
+
data-field={error || hint ? '' : undefined}
|
|
144
|
+
{id}
|
|
145
|
+
class={cn('flex flex-wrap gap-1.5', klass)}
|
|
146
|
+
role="group"
|
|
147
|
+
aria-label={label}
|
|
148
|
+
aria-describedby={describedByIds}
|
|
149
|
+
>
|
|
100
150
|
{#each options as chip (chip.value)}
|
|
101
151
|
{@const pressed = chip.value === value}
|
|
102
152
|
{#if linked}
|
|
@@ -122,6 +172,11 @@
|
|
|
122
172
|
{/if}
|
|
123
173
|
{/each}
|
|
124
174
|
</div>
|
|
175
|
+
{#if error}
|
|
176
|
+
<p id="{messageBase}-error" class="{FIELD_ERROR} mt-1.5">{error}</p>
|
|
177
|
+
{:else if hint}
|
|
178
|
+
<p id="{messageBase}-hint" class="{FIELD_HINT} mt-1.5">{hint}</p>
|
|
179
|
+
{/if}
|
|
125
180
|
|
|
126
181
|
{#snippet face(chip: FilterChip<T>)}
|
|
127
182
|
{#if chip.icon}
|
|
@@ -30,6 +30,24 @@ declare function $$render<T extends string>(): {
|
|
|
30
30
|
label: string;
|
|
31
31
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
32
32
|
countLabel?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The group's id. The hint and the error take theirs from it. Left unset,
|
|
35
|
+
* the group carries no id and the messages take a generated one.
|
|
36
|
+
*/
|
|
37
|
+
id?: string;
|
|
38
|
+
/** Shown under the row while there is no error. */
|
|
39
|
+
hint?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Replaces the hint while it is set. The group points at it and is not
|
|
42
|
+
* marked invalid, because ARIA refuses that attribute on a group.
|
|
43
|
+
*/
|
|
44
|
+
error?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
47
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
48
|
+
* own hint or error.
|
|
49
|
+
*/
|
|
50
|
+
describedBy?: string;
|
|
33
51
|
class?: string;
|
|
34
52
|
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
35
53
|
onchange?: (value: T) => void;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
FIELD_WRAP,
|
|
8
8
|
controlBorder,
|
|
9
9
|
describedBy,
|
|
10
|
+
joinIds,
|
|
10
11
|
} from '../internal/field.js';
|
|
11
12
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
12
13
|
|
|
@@ -27,6 +28,12 @@
|
|
|
27
28
|
placeholder?: string;
|
|
28
29
|
hint?: string;
|
|
29
30
|
error?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Ids of other elements that describe the input, separated by spaces, such
|
|
33
|
+
* as the message a wrapping Field renders. They are read after the input's
|
|
34
|
+
* own hint or error.
|
|
35
|
+
*/
|
|
36
|
+
describedBy?: string;
|
|
30
37
|
disabled?: boolean;
|
|
31
38
|
required?: boolean;
|
|
32
39
|
id?: string;
|
|
@@ -47,6 +54,7 @@
|
|
|
47
54
|
placeholder = '',
|
|
48
55
|
hint = undefined,
|
|
49
56
|
error = undefined,
|
|
57
|
+
describedBy: callerIds = undefined,
|
|
50
58
|
disabled = false,
|
|
51
59
|
required = false,
|
|
52
60
|
id = undefined,
|
|
@@ -73,7 +81,7 @@
|
|
|
73
81
|
{required}
|
|
74
82
|
bind:value
|
|
75
83
|
aria-invalid={error ? 'true' : undefined}
|
|
76
|
-
aria-describedby={describedBy(fieldId, error, hint)}
|
|
84
|
+
aria-describedby={joinIds(describedBy(fieldId, error, hint), callerIds)}
|
|
77
85
|
class="{CONTROL_BASE} {controlBorder(!!error)} {mono ? 'font-mono' : ''}"
|
|
78
86
|
{...rest}
|
|
79
87
|
/>
|
|
@@ -12,6 +12,12 @@ interface Props extends Forwarded {
|
|
|
12
12
|
placeholder?: string;
|
|
13
13
|
hint?: string;
|
|
14
14
|
error?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Ids of other elements that describe the input, separated by spaces, such
|
|
17
|
+
* as the message a wrapping Field renders. They are read after the input's
|
|
18
|
+
* own hint or error.
|
|
19
|
+
*/
|
|
20
|
+
describedBy?: string;
|
|
15
21
|
disabled?: boolean;
|
|
16
22
|
required?: boolean;
|
|
17
23
|
id?: string;
|
|
@@ -62,12 +62,16 @@
|
|
|
62
62
|
Each size token names its own leading and tracking. A `text-*` token
|
|
63
63
|
built from `--text-*` alone sets font-size and leaves line-height to
|
|
64
64
|
whatever it inherits, which is not the ratio the ramp specifies.
|
|
65
|
+
|
|
66
|
+
A title can be a user value with no spaces, such as a tenant slug. It
|
|
67
|
+
breaks anywhere only when a word does not fit the line, so a title with
|
|
68
|
+
spaces still wraps at them, and a long slug no longer widens the page.
|
|
65
69
|
-->
|
|
66
70
|
<h1
|
|
67
71
|
data-testid="page-title"
|
|
68
|
-
class={compact
|
|
72
|
+
class="[overflow-wrap:anywhere] {compact
|
|
69
73
|
? 'text-sm font-semibold text-fg'
|
|
70
|
-
: 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}
|
|
74
|
+
: 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}"
|
|
71
75
|
>
|
|
72
76
|
{title}
|
|
73
77
|
</h1>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
type ChoiceSize,
|
|
34
34
|
type ChoiceVariant,
|
|
35
35
|
} from '../internal/choice.js';
|
|
36
|
-
import { FIELD_ERROR, FIELD_HINT, describedBy } from '../internal/field.js';
|
|
36
|
+
import { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
|
|
37
37
|
import Radio from './Radio.svelte';
|
|
38
38
|
|
|
39
39
|
interface Props {
|
|
@@ -48,8 +48,19 @@
|
|
|
48
48
|
label: string;
|
|
49
49
|
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
50
50
|
labelHidden?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The fieldset's id. The hint and the error take theirs from it. Left
|
|
53
|
+
* unset, the fieldset carries no id and the messages take a generated one.
|
|
54
|
+
*/
|
|
55
|
+
id?: string;
|
|
51
56
|
hint?: string;
|
|
52
57
|
error?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
60
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
61
|
+
* own hint or error.
|
|
62
|
+
*/
|
|
63
|
+
describedBy?: string;
|
|
53
64
|
name?: string;
|
|
54
65
|
orientation?: ChoiceOrientation;
|
|
55
66
|
size?: ChoiceSize;
|
|
@@ -65,8 +76,10 @@
|
|
|
65
76
|
options,
|
|
66
77
|
label,
|
|
67
78
|
labelHidden = false,
|
|
79
|
+
id = undefined,
|
|
68
80
|
hint = undefined,
|
|
69
81
|
error = undefined,
|
|
82
|
+
describedBy: callerIds = undefined,
|
|
70
83
|
name = undefined,
|
|
71
84
|
orientation = 'vertical',
|
|
72
85
|
size = 'md',
|
|
@@ -87,6 +100,7 @@
|
|
|
87
100
|
*/
|
|
88
101
|
const uid = $props.id();
|
|
89
102
|
const groupName = $derived(name ?? uid);
|
|
103
|
+
const messageBase = $derived(id ?? uid);
|
|
90
104
|
</script>
|
|
91
105
|
|
|
92
106
|
<!--
|
|
@@ -97,12 +111,13 @@
|
|
|
97
111
|
-->
|
|
98
112
|
<fieldset
|
|
99
113
|
data-field
|
|
114
|
+
{id}
|
|
100
115
|
class="{CHOICE_GROUP} {cls}"
|
|
101
116
|
role="radiogroup"
|
|
102
117
|
{disabled}
|
|
103
118
|
aria-invalid={error ? 'true' : undefined}
|
|
104
119
|
aria-required={required ? 'true' : undefined}
|
|
105
|
-
aria-describedby={describedBy(
|
|
120
|
+
aria-describedby={joinIds(describedBy(messageBase, error, hint), callerIds)}
|
|
106
121
|
>
|
|
107
122
|
<!--
|
|
108
123
|
The legend stays a legend when it is hidden. Swapping it for an aria-label
|
|
@@ -139,8 +154,8 @@
|
|
|
139
154
|
</div>
|
|
140
155
|
|
|
141
156
|
{#if error}
|
|
142
|
-
<p id="{
|
|
157
|
+
<p id="{messageBase}-error" class={FIELD_ERROR}>{error}</p>
|
|
143
158
|
{:else if hint}
|
|
144
|
-
<p id="{
|
|
159
|
+
<p id="{messageBase}-hint" class={FIELD_HINT}>{hint}</p>
|
|
145
160
|
{/if}
|
|
146
161
|
</fieldset>
|
|
@@ -36,8 +36,19 @@ interface Props {
|
|
|
36
36
|
label: string;
|
|
37
37
|
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
38
38
|
labelHidden?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The fieldset's id. The hint and the error take theirs from it. Left
|
|
41
|
+
* unset, the fieldset carries no id and the messages take a generated one.
|
|
42
|
+
*/
|
|
43
|
+
id?: string;
|
|
39
44
|
hint?: string;
|
|
40
45
|
error?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
48
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
49
|
+
* own hint or error.
|
|
50
|
+
*/
|
|
51
|
+
describedBy?: string;
|
|
41
52
|
name?: string;
|
|
42
53
|
orientation?: ChoiceOrientation;
|
|
43
54
|
size?: ChoiceSize;
|
|
@@ -21,7 +21,14 @@
|
|
|
21
21
|
</script>
|
|
22
22
|
|
|
23
23
|
<script lang="ts" generics="T extends string">
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
FIELD_ERROR,
|
|
26
|
+
FIELD_HINT,
|
|
27
|
+
FIELD_LABEL,
|
|
28
|
+
FIELD_WRAP,
|
|
29
|
+
describedBy,
|
|
30
|
+
joinIds,
|
|
31
|
+
} from '../internal/field.js';
|
|
25
32
|
import { safeHref } from '../internal/href.js';
|
|
26
33
|
import { HIT_AREA } from '../internal/touch.js';
|
|
27
34
|
|
|
@@ -39,6 +46,24 @@
|
|
|
39
46
|
label: string;
|
|
40
47
|
/** Drops the caption above the row. The group keeps its accessible name. */
|
|
41
48
|
labelHidden?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The group's id. The hint and the error take theirs from it. Left unset,
|
|
51
|
+
* the group carries no id and the messages take a generated one.
|
|
52
|
+
*/
|
|
53
|
+
id?: string;
|
|
54
|
+
/** Shown under the row while there is no error. */
|
|
55
|
+
hint?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Replaces the hint while it is set. A row of radios is marked invalid
|
|
58
|
+
* with it. A row of links is not, because ARIA refuses the attribute there.
|
|
59
|
+
*/
|
|
60
|
+
error?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
63
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
64
|
+
* own hint or error.
|
|
65
|
+
*/
|
|
66
|
+
describedBy?: string;
|
|
42
67
|
/** Submits the value with the surrounding form, through a hidden input. */
|
|
43
68
|
name?: string;
|
|
44
69
|
size?: Size;
|
|
@@ -53,6 +78,10 @@
|
|
|
53
78
|
options,
|
|
54
79
|
label,
|
|
55
80
|
labelHidden = false,
|
|
81
|
+
id = undefined,
|
|
82
|
+
hint = undefined,
|
|
83
|
+
error = undefined,
|
|
84
|
+
describedBy: callerIds = undefined,
|
|
56
85
|
name = undefined,
|
|
57
86
|
size = 'md',
|
|
58
87
|
disabled = false,
|
|
@@ -76,6 +105,15 @@
|
|
|
76
105
|
md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
|
|
77
106
|
};
|
|
78
107
|
|
|
108
|
+
/*
|
|
109
|
+
* $props.id() and not Math.random(), so the message ids match between the
|
|
110
|
+
* server render and hydration. The group carries an id only when the caller
|
|
111
|
+
* names one, so a control given none keeps the attributes it had.
|
|
112
|
+
*/
|
|
113
|
+
const uid = $props.id();
|
|
114
|
+
const messageBase = $derived(id ?? uid);
|
|
115
|
+
const describedByIds = $derived(joinIds(describedBy(messageBase, error, hint), callerIds));
|
|
116
|
+
|
|
79
117
|
let rootEl: HTMLDivElement | undefined = $state();
|
|
80
118
|
|
|
81
119
|
/*
|
|
@@ -188,15 +226,20 @@
|
|
|
188
226
|
|
|
189
227
|
<!-- A set of links is a group, not a radiogroup: a link is not a radio and
|
|
190
228
|
carries no aria-checked, and what marks the current one is aria-current,
|
|
191
|
-
the attribute a reader already knows from navigation.
|
|
229
|
+
the attribute a reader already knows from navigation. ARIA allows
|
|
230
|
+
aria-invalid on a radiogroup and not on a group, so a row of links
|
|
231
|
+
reports an error through aria-describedby alone. -->
|
|
192
232
|
<div
|
|
193
233
|
bind:this={rootEl}
|
|
234
|
+
{id}
|
|
194
235
|
role={linked ? 'group' : 'radiogroup'}
|
|
195
236
|
aria-label={label}
|
|
237
|
+
aria-invalid={error && !linked ? 'true' : undefined}
|
|
238
|
+
aria-describedby={describedByIds}
|
|
196
239
|
onfocusout={linked ? onGroupFocusOut : undefined}
|
|
197
|
-
class="inline-flex items-stretch rounded-lg border
|
|
198
|
-
|
|
199
|
-
|
|
240
|
+
class="inline-flex items-stretch rounded-lg border {error
|
|
241
|
+
? 'border-danger'
|
|
242
|
+
: 'border-line-strong'} bg-surface-2 p-0.5 {SIZES[size].row} {disabled ? 'opacity-50' : ''}"
|
|
200
243
|
>
|
|
201
244
|
{#each options as option, i (option.value)}
|
|
202
245
|
{@const selected = option.value === value}
|
|
@@ -267,6 +310,12 @@
|
|
|
267
310
|
{/each}
|
|
268
311
|
</div>
|
|
269
312
|
|
|
313
|
+
{#if error}
|
|
314
|
+
<p id="{messageBase}-error" class={FIELD_ERROR}>{error}</p>
|
|
315
|
+
{:else if hint}
|
|
316
|
+
<p id="{messageBase}-hint" class={FIELD_HINT}>{hint}</p>
|
|
317
|
+
{/if}
|
|
318
|
+
|
|
270
319
|
{#if name}
|
|
271
320
|
<!-- A div with role="radiogroup" submits nothing. The value reaches a form
|
|
272
321
|
post through this and not through the segments, which are buttons and
|
|
@@ -32,6 +32,24 @@ declare function $$render<T extends string>(): {
|
|
|
32
32
|
label: string;
|
|
33
33
|
/** Drops the caption above the row. The group keeps its accessible name. */
|
|
34
34
|
labelHidden?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The group's id. The hint and the error take theirs from it. Left unset,
|
|
37
|
+
* the group carries no id and the messages take a generated one.
|
|
38
|
+
*/
|
|
39
|
+
id?: string;
|
|
40
|
+
/** Shown under the row while there is no error. */
|
|
41
|
+
hint?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Replaces the hint while it is set. A row of radios is marked invalid
|
|
44
|
+
* with it. A row of links is not, because ARIA refuses the attribute there.
|
|
45
|
+
*/
|
|
46
|
+
error?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Ids of other elements that describe the group, separated by spaces, such
|
|
49
|
+
* as the message a wrapping Field renders. They are read after the group's
|
|
50
|
+
* own hint or error.
|
|
51
|
+
*/
|
|
52
|
+
describedBy?: string;
|
|
35
53
|
/** Submits the value with the surrounding form, through a hidden input. */
|
|
36
54
|
name?: string;
|
|
37
55
|
size?: "sm" | "md";
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
FIELD_WRAP,
|
|
54
54
|
controlBorder,
|
|
55
55
|
describedBy,
|
|
56
|
+
joinIds,
|
|
56
57
|
} from '../internal/field.js';
|
|
57
58
|
import { createListbox } from '../internal/listbox.svelte.js';
|
|
58
59
|
import {
|
|
@@ -75,6 +76,12 @@
|
|
|
75
76
|
required?: boolean;
|
|
76
77
|
disabled?: boolean;
|
|
77
78
|
error?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Ids of other elements that describe the control, separated by spaces,
|
|
81
|
+
* such as the message a wrapping Field renders. They are read after the
|
|
82
|
+
* control's own hint or error.
|
|
83
|
+
*/
|
|
84
|
+
describedBy?: string;
|
|
78
85
|
class?: string;
|
|
79
86
|
/** The rows, as data. */
|
|
80
87
|
options?: SelectOption[];
|
|
@@ -102,6 +109,7 @@
|
|
|
102
109
|
required = false,
|
|
103
110
|
disabled = false,
|
|
104
111
|
error,
|
|
112
|
+
describedBy: callerIds,
|
|
105
113
|
class: cls = '',
|
|
106
114
|
options = [],
|
|
107
115
|
searchable = false,
|
|
@@ -312,7 +320,7 @@
|
|
|
312
320
|
}}
|
|
313
321
|
aria-invalid={error ? 'true' : undefined}
|
|
314
322
|
aria-required={required ? 'true' : undefined}
|
|
315
|
-
aria-describedby={describedBy(fieldId, error, hint)}
|
|
323
|
+
aria-describedby={joinIds(describedBy(fieldId, error, hint), callerIds)}
|
|
316
324
|
{...box.triggerAttrs}
|
|
317
325
|
class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
|
|
318
326
|
text-start"
|
|
@@ -53,6 +53,12 @@ interface Props {
|
|
|
53
53
|
required?: boolean;
|
|
54
54
|
disabled?: boolean;
|
|
55
55
|
error?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Ids of other elements that describe the control, separated by spaces,
|
|
58
|
+
* such as the message a wrapping Field renders. They are read after the
|
|
59
|
+
* control's own hint or error.
|
|
60
|
+
*/
|
|
61
|
+
describedBy?: string;
|
|
56
62
|
class?: string;
|
|
57
63
|
/** The rows, as data. */
|
|
58
64
|
options?: SelectOption[];
|
package/dist/index.d.ts
CHANGED
|
@@ -103,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
|
|
|
103
103
|
export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
|
|
104
104
|
export * as motion from './motion.js';
|
|
105
105
|
export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
|
|
106
|
-
export declare const VERSION = "0.
|
|
106
|
+
export declare const VERSION = "0.38.1";
|
package/dist/index.js
CHANGED
|
@@ -116,4 +116,4 @@ export * as motion from './motion.js';
|
|
|
116
116
|
// ── Version ────────────────────────────────────────────────────────────────
|
|
117
117
|
// Generated from package.json by `pnpm version:sync`. Bump package.json, never
|
|
118
118
|
// this line. The build and the test suite fail when the two disagree.
|
|
119
|
-
export const VERSION = '0.
|
|
119
|
+
export const VERSION = '0.38.1';
|
package/dist/internal/field.d.ts
CHANGED
|
@@ -105,3 +105,11 @@ export declare function segmentedBorder(error: boolean): string;
|
|
|
105
105
|
* by the wrapper's own rounding.
|
|
106
106
|
*/
|
|
107
107
|
export declare const CONTROL_SEGMENT: string;
|
|
108
|
+
/**
|
|
109
|
+
* Joins a control's own message id with the ids its caller names.
|
|
110
|
+
*
|
|
111
|
+
* A control inside a Field, or explained by text elsewhere on the page, has to
|
|
112
|
+
* point at that text as well as at its own hint or error. Returns undefined
|
|
113
|
+
* when there is nothing to point at, for the reason describedBy does.
|
|
114
|
+
*/
|
|
115
|
+
export declare function joinIds(...ids: (string | undefined)[]): string | undefined;
|
package/dist/internal/field.js
CHANGED
|
@@ -134,3 +134,13 @@ export function segmentedBorder(error) {
|
|
|
134
134
|
export const CONTROL_SEGMENT = 'shrink-0 rounded-sm border-0 bg-transparent p-0 text-center text-sm text-fg tabular-nums ' +
|
|
135
135
|
'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
|
|
136
136
|
'disabled:cursor-not-allowed';
|
|
137
|
+
/**
|
|
138
|
+
* Joins a control's own message id with the ids its caller names.
|
|
139
|
+
*
|
|
140
|
+
* A control inside a Field, or explained by text elsewhere on the page, has to
|
|
141
|
+
* point at that text as well as at its own hint or error. Returns undefined
|
|
142
|
+
* when there is nothing to point at, for the reason describedBy does.
|
|
143
|
+
*/
|
|
144
|
+
export function joinIds(...ids) {
|
|
145
|
+
return ids.filter(Boolean).join(' ') || undefined;
|
|
146
|
+
}
|
package/package.json
CHANGED