@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
|
@@ -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,19 +31,43 @@
|
|
|
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 { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
|
|
40
|
+
import { safeHref } from '../internal/href.js';
|
|
28
41
|
import { HIT_AREA } from '../internal/touch.js';
|
|
29
42
|
|
|
30
43
|
interface Props {
|
|
31
|
-
/** The pressed chip's value. */
|
|
44
|
+
/** The pressed chip's value. In link mode, the value of the current chip. */
|
|
32
45
|
value: T;
|
|
33
46
|
options: FilterChip<T>[];
|
|
34
47
|
/** Names the group for assistive technology. The chips name themselves. */
|
|
35
48
|
label: string;
|
|
36
49
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
37
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;
|
|
38
69
|
class?: string;
|
|
70
|
+
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
39
71
|
onchange?: (value: T) => void;
|
|
40
72
|
}
|
|
41
73
|
|
|
@@ -44,43 +76,117 @@
|
|
|
44
76
|
options,
|
|
45
77
|
label,
|
|
46
78
|
countLabel = '',
|
|
79
|
+
id = undefined,
|
|
80
|
+
hint = undefined,
|
|
81
|
+
error = undefined,
|
|
82
|
+
describedBy: callerIds = undefined,
|
|
47
83
|
class: klass = '',
|
|
48
84
|
onchange = undefined,
|
|
49
85
|
}: Props = $props();
|
|
50
86
|
|
|
87
|
+
/*
|
|
88
|
+
* Link mode when every chip carries an href. A row that mixes the two is
|
|
89
|
+
* refused outright, for the reason SegmentedControl refuses one: a button
|
|
90
|
+
* beside two links would report a choice that the URL never learns about,
|
|
91
|
+
* and the caller has no way to see that from the screen.
|
|
92
|
+
*/
|
|
93
|
+
const linked = $derived.by(() => {
|
|
94
|
+
const count = options.filter((o) => o.href !== undefined).length;
|
|
95
|
+
if (count !== 0 && count !== options.length) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`FilterChips: every option carries href or none does, ${count} of ${options.length} did`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return count !== 0;
|
|
101
|
+
});
|
|
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
|
+
|
|
51
112
|
function pick(next: T): void {
|
|
52
113
|
if (next === value) return;
|
|
53
114
|
value = next;
|
|
54
115
|
onchange?.(next);
|
|
55
116
|
}
|
|
117
|
+
|
|
118
|
+
/** One treatment for both modes, so a current link reads as a pressed chip. */
|
|
119
|
+
function chipClass(pressed: boolean): string {
|
|
120
|
+
return cn(
|
|
121
|
+
HIT_AREA,
|
|
122
|
+
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
|
|
123
|
+
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
|
|
124
|
+
pressed
|
|
125
|
+
? 'border-brand bg-brand/10 text-fg'
|
|
126
|
+
: 'border-line text-muted hover:border-line-strong hover:text-fg',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
56
129
|
</script>
|
|
57
130
|
|
|
58
|
-
|
|
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
|
+
>
|
|
59
150
|
{#each options as chip (chip.value)}
|
|
60
151
|
{@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>
|
|
152
|
+
{#if linked}
|
|
153
|
+
<!-- A plain anchor with no click handler, so choosing a chip is a
|
|
154
|
+
navigation the browser performs on its own, before the page has
|
|
155
|
+
hydrated as well as after. -->
|
|
156
|
+
<a
|
|
157
|
+
href={safeHref(chip.href)}
|
|
158
|
+
aria-current={pressed ? 'page' : undefined}
|
|
159
|
+
class={chipClass(pressed)}
|
|
160
|
+
>
|
|
161
|
+
{@render face(chip)}
|
|
162
|
+
</a>
|
|
163
|
+
{:else}
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
aria-pressed={pressed}
|
|
167
|
+
class={chipClass(pressed)}
|
|
168
|
+
onclick={() => pick(chip.value)}
|
|
169
|
+
>
|
|
170
|
+
{@render face(chip)}
|
|
171
|
+
</button>
|
|
172
|
+
{/if}
|
|
85
173
|
{/each}
|
|
86
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}
|
|
180
|
+
|
|
181
|
+
{#snippet face(chip: FilterChip<T>)}
|
|
182
|
+
{#if chip.icon}
|
|
183
|
+
{@const Icon = chip.icon}
|
|
184
|
+
<Icon size={12} class="shrink-0" />
|
|
185
|
+
{/if}
|
|
186
|
+
{chip.label}
|
|
187
|
+
{#if chip.count !== undefined}
|
|
188
|
+
<span class="font-mono text-[10px] text-faint"
|
|
189
|
+
>{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
|
|
190
|
+
>
|
|
191
|
+
{/if}
|
|
192
|
+
{/snippet}
|
|
@@ -12,17 +12,44 @@ 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. */
|
|
22
30
|
label: string;
|
|
23
31
|
/** Read after the count, so "3" is heard as "3 on sale". */
|
|
24
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;
|
|
25
51
|
class?: string;
|
|
52
|
+
/** Button mode only. A link chip reports nothing. The page it opens does. */
|
|
26
53
|
onchange?: (value: T) => void;
|
|
27
54
|
};
|
|
28
55
|
exports: {};
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -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,8 +21,16 @@
|
|
|
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';
|
|
33
|
+
import { HIT_AREA } from '../internal/touch.js';
|
|
26
34
|
|
|
27
35
|
type Size = 'sm' | 'md';
|
|
28
36
|
|
|
@@ -38,6 +46,24 @@
|
|
|
38
46
|
label: string;
|
|
39
47
|
/** Drops the caption above the row. The group keeps its accessible name. */
|
|
40
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;
|
|
41
67
|
/** Submits the value with the surrounding form, through a hidden input. */
|
|
42
68
|
name?: string;
|
|
43
69
|
size?: Size;
|
|
@@ -52,6 +78,10 @@
|
|
|
52
78
|
options,
|
|
53
79
|
label,
|
|
54
80
|
labelHidden = false,
|
|
81
|
+
id = undefined,
|
|
82
|
+
hint = undefined,
|
|
83
|
+
error = undefined,
|
|
84
|
+
describedBy: callerIds = undefined,
|
|
55
85
|
name = undefined,
|
|
56
86
|
size = 'md',
|
|
57
87
|
disabled = false,
|
|
@@ -64,12 +94,26 @@
|
|
|
64
94
|
* Input in the same row line up. The segments stretch inside it rather than
|
|
65
95
|
* setting their own height, which would add the row's padding and border on
|
|
66
96
|
* top and stand the control two pixels proud of everything beside it.
|
|
97
|
+
*
|
|
98
|
+
* Under a finger the small row grows to the control height, as a small
|
|
99
|
+
* Button does, and each segment keeps its visual inside the track and takes
|
|
100
|
+
* a hit box of the control size. A segment that grew instead would stand
|
|
101
|
+
* the track six pixels proud of the Input beside it.
|
|
67
102
|
*/
|
|
68
103
|
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 },
|
|
104
|
+
sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
|
|
70
105
|
md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
|
|
71
106
|
};
|
|
72
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
|
+
|
|
73
117
|
let rootEl: HTMLDivElement | undefined = $state();
|
|
74
118
|
|
|
75
119
|
/*
|
|
@@ -182,21 +226,26 @@
|
|
|
182
226
|
|
|
183
227
|
<!-- A set of links is a group, not a radiogroup: a link is not a radio and
|
|
184
228
|
carries no aria-checked, and what marks the current one is aria-current,
|
|
185
|
-
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. -->
|
|
186
232
|
<div
|
|
187
233
|
bind:this={rootEl}
|
|
234
|
+
{id}
|
|
188
235
|
role={linked ? 'group' : 'radiogroup'}
|
|
189
236
|
aria-label={label}
|
|
237
|
+
aria-invalid={error && !linked ? 'true' : undefined}
|
|
238
|
+
aria-describedby={describedByIds}
|
|
190
239
|
onfocusout={linked ? onGroupFocusOut : undefined}
|
|
191
|
-
class="inline-flex items-stretch rounded-lg border
|
|
192
|
-
|
|
193
|
-
|
|
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' : ''}"
|
|
194
243
|
>
|
|
195
244
|
{#each options as option, i (option.value)}
|
|
196
245
|
{@const selected = option.value === value}
|
|
197
246
|
{@const Icon = option.icon}
|
|
198
247
|
{@const segment =
|
|
199
|
-
|
|
248
|
+
`${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
249
|
SIZES[size].segment +
|
|
201
250
|
(selected
|
|
202
251
|
? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
|
|
@@ -261,6 +310,12 @@
|
|
|
261
310
|
{/each}
|
|
262
311
|
</div>
|
|
263
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
|
+
|
|
264
319
|
{#if name}
|
|
265
320
|
<!-- A div with role="radiogroup" submits nothing. The value reaches a form
|
|
266
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";
|