@lyeve-labs/ui-kit 0.12.1 → 0.13.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/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Card.svelte +43 -2
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -63
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +43 -7
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -35
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +264 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +39 -0
- package/dist/internal/field.js +48 -0
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +18 -10
- package/package.json +4 -2
- package/src/lib/styles/theme.css +18 -10
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
import { FIELD_ERROR, FIELD_HINT, FIELD_WRAP } from '../internal/field.js';
|
|
4
|
+
import {
|
|
5
|
+
CHOICE_DESCRIPTION,
|
|
6
|
+
CHOICE_FOCUS,
|
|
7
|
+
CHOICE_ICON_PX,
|
|
8
|
+
CHOICE_INPUT,
|
|
9
|
+
CHOICE_LABEL_STACK,
|
|
10
|
+
CHOICE_MARK,
|
|
11
|
+
choiceBox,
|
|
12
|
+
choiceIcon,
|
|
13
|
+
choiceLabel,
|
|
14
|
+
choiceWrap,
|
|
15
|
+
type ChoiceSize,
|
|
16
|
+
type ChoiceVariant,
|
|
17
|
+
} from '../internal/choice.js';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
15
20
|
checked?: boolean;
|
|
21
|
+
/** Partly checked. Announced as aria-checked="mixed". */
|
|
22
|
+
indeterminate?: boolean;
|
|
16
23
|
label?: string;
|
|
24
|
+
/** Screen-reader-only label, for a checkbox in a table cell whose column header is the visible name. */
|
|
25
|
+
labelHidden?: boolean;
|
|
26
|
+
/** Secondary line under the label. */
|
|
27
|
+
description?: string;
|
|
17
28
|
hint?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
/** Drawn before the label. */
|
|
31
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
32
|
+
size?: ChoiceSize;
|
|
33
|
+
/** 'card' draws a bordered option whose whole surface is the target. */
|
|
34
|
+
variant?: ChoiceVariant;
|
|
18
35
|
required?: boolean;
|
|
19
36
|
disabled?: boolean;
|
|
20
37
|
id?: string;
|
|
@@ -22,66 +39,160 @@
|
|
|
22
39
|
value?: string;
|
|
23
40
|
class?: string;
|
|
24
41
|
onchange?: (checked: boolean) => void;
|
|
25
|
-
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
checked = $bindable(false),
|
|
46
|
+
indeterminate = $bindable(false),
|
|
47
|
+
label = undefined,
|
|
48
|
+
labelHidden = false,
|
|
49
|
+
description = undefined,
|
|
50
|
+
hint = undefined,
|
|
51
|
+
error = undefined,
|
|
52
|
+
icon: Icon = undefined,
|
|
53
|
+
size = 'md',
|
|
54
|
+
variant = 'inline',
|
|
55
|
+
required = false,
|
|
56
|
+
disabled = false,
|
|
57
|
+
id = undefined,
|
|
58
|
+
name = undefined,
|
|
59
|
+
value = undefined,
|
|
60
|
+
class: cls = '',
|
|
61
|
+
onchange = undefined,
|
|
62
|
+
}: Props = $props();
|
|
63
|
+
|
|
64
|
+
// $props.id() and not a random string: a random id differs between the server
|
|
65
|
+
// render and hydration, so every aria-describedby built from it points at an
|
|
66
|
+
// element that does not exist on the client.
|
|
67
|
+
const uid = $props.id();
|
|
68
|
+
const fieldId = $derived(id ?? uid);
|
|
69
|
+
|
|
70
|
+
// Error, then hint, then description. A screen reader announces one message,
|
|
71
|
+
// so the most urgent one has to win rather than the last one rendered.
|
|
72
|
+
const messageId = $derived(
|
|
73
|
+
error
|
|
74
|
+
? `${fieldId}-error`
|
|
75
|
+
: hint
|
|
76
|
+
? `${fieldId}-hint`
|
|
77
|
+
: description
|
|
78
|
+
? `${fieldId}-description`
|
|
79
|
+
: undefined,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// A part-checked box is on, not a third colour, so it paints the filled box
|
|
83
|
+
// and tints the card exactly as a checked one does.
|
|
84
|
+
const on = $derived(checked || indeterminate);
|
|
85
|
+
|
|
86
|
+
// A card fills its column. An inline option must not: stretched to the full
|
|
87
|
+
// width of the field wrapper, the label swallows the empty space beside it
|
|
88
|
+
// and a stray click far from the box toggles the value.
|
|
89
|
+
const rootClass = $derived(variant === 'card' ? FIELD_WRAP : `${FIELD_WRAP} items-start`);
|
|
26
90
|
|
|
27
91
|
function handleChange(e: Event & { currentTarget: HTMLInputElement }) {
|
|
92
|
+
// Disabled is enforced here as well as on the input. The native attribute
|
|
93
|
+
// is what stops a real click, and it is the only thing that does, so a
|
|
94
|
+
// change arriving any other way - a synthetic event, a script driving the
|
|
95
|
+
// node - would still move the bound value with nothing on screen to say so.
|
|
96
|
+
if (disabled) return;
|
|
97
|
+
// The DOM clears indeterminate on the first click. Leaving the prop set
|
|
98
|
+
// would repaint the mixed bar over a box the user has just ticked.
|
|
99
|
+
indeterminate = false;
|
|
28
100
|
checked = e.currentTarget.checked;
|
|
29
101
|
onchange?.(checked);
|
|
30
102
|
}
|
|
31
103
|
</script>
|
|
32
104
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/>
|
|
70
|
-
</svg>
|
|
71
|
-
{/if}
|
|
72
|
-
</span>
|
|
105
|
+
{#snippet control()}
|
|
106
|
+
<input
|
|
107
|
+
type="checkbox"
|
|
108
|
+
id={fieldId}
|
|
109
|
+
{name}
|
|
110
|
+
{value}
|
|
111
|
+
{required}
|
|
112
|
+
{disabled}
|
|
113
|
+
{checked}
|
|
114
|
+
{indeterminate}
|
|
115
|
+
aria-checked={indeterminate ? 'mixed' : undefined}
|
|
116
|
+
aria-invalid={error ? 'true' : undefined}
|
|
117
|
+
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
118
|
+
aria-describedby={messageId}
|
|
119
|
+
onchange={handleChange}
|
|
120
|
+
class={CHOICE_INPUT}
|
|
121
|
+
/>
|
|
122
|
+
{/snippet}
|
|
123
|
+
|
|
124
|
+
{#snippet mark()}
|
|
125
|
+
<span class="{choiceBox('checkbox', size, checked, indeterminate)} mt-0.5">
|
|
126
|
+
{#if on}
|
|
127
|
+
<!-- Both marks are stroked paths on one 10 by 8 grid. A Unicode check
|
|
128
|
+
lands at whatever weight the reader's font gives it, visibly lighter
|
|
129
|
+
than every other icon in the kit, and the mixed bar has no glyph that
|
|
130
|
+
is not a minus sign standing in for one. -->
|
|
131
|
+
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
132
|
+
<path
|
|
133
|
+
d={indeterminate ? CHOICE_MARK.mixed : CHOICE_MARK.check}
|
|
134
|
+
stroke="currentColor"
|
|
135
|
+
stroke-width="1.5"
|
|
136
|
+
stroke-linecap="round"
|
|
137
|
+
stroke-linejoin="round"
|
|
138
|
+
/>
|
|
139
|
+
</svg>
|
|
140
|
+
{/if}
|
|
73
141
|
</span>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
142
|
+
{/snippet}
|
|
143
|
+
|
|
144
|
+
{#snippet body()}
|
|
145
|
+
{#if label || description || Icon}
|
|
146
|
+
<span class={CHOICE_LABEL_STACK}>
|
|
147
|
+
{#if label || Icon}
|
|
148
|
+
<span class="flex items-center gap-2">
|
|
149
|
+
{#if Icon}
|
|
150
|
+
<span class={choiceIcon(size)}><Icon size={CHOICE_ICON_PX[size]} /></span>
|
|
151
|
+
{/if}
|
|
152
|
+
{#if label}
|
|
153
|
+
<!-- Named by aria-labelledby rather than by the wrapping label, so
|
|
154
|
+
the description below stays a description. Read as the label's
|
|
155
|
+
own text it became part of the name, and the reader heard the
|
|
156
|
+
whole paragraph before it reached the checked state. -->
|
|
157
|
+
<span id="{fieldId}-label" class="{choiceLabel(size)} {labelHidden ? 'sr-only' : ''}">
|
|
158
|
+
{label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
159
|
+
</span>
|
|
160
|
+
{/if}
|
|
80
161
|
</span>
|
|
81
162
|
{/if}
|
|
82
|
-
{#if
|
|
83
|
-
<span class={
|
|
163
|
+
{#if description}
|
|
164
|
+
<span id="{fieldId}-description" class={CHOICE_DESCRIPTION}>{description}</span>
|
|
84
165
|
{/if}
|
|
85
166
|
</span>
|
|
86
167
|
{/if}
|
|
87
|
-
|
|
168
|
+
{/snippet}
|
|
169
|
+
|
|
170
|
+
<div class="{rootClass} {cls}">
|
|
171
|
+
{#if variant === 'card'}
|
|
172
|
+
<!-- The input covers the whole card, so the card surface is the element the
|
|
173
|
+
peer ring can reach and the box inside it is not. That is deliberate:
|
|
174
|
+
one control gets one focus indicator, and a ring on the box drawn
|
|
175
|
+
inside a ring on the card reads as two separate things to focus. -->
|
|
176
|
+
<label class="relative block">
|
|
177
|
+
{@render control()}
|
|
178
|
+
<span class="{choiceWrap('card', on, disabled)} {CHOICE_FOCUS}">
|
|
179
|
+
{@render mark()}
|
|
180
|
+
{@render body()}
|
|
181
|
+
</span>
|
|
182
|
+
</label>
|
|
183
|
+
{:else}
|
|
184
|
+
<label class={choiceWrap('inline', on, disabled)}>
|
|
185
|
+
<span class="relative flex shrink-0 items-center justify-center">
|
|
186
|
+
{@render control()}
|
|
187
|
+
{@render mark()}
|
|
188
|
+
</span>
|
|
189
|
+
{@render body()}
|
|
190
|
+
</label>
|
|
191
|
+
{/if}
|
|
192
|
+
|
|
193
|
+
{#if error}
|
|
194
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
195
|
+
{:else if hint}
|
|
196
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
197
|
+
{/if}
|
|
198
|
+
</div>
|
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
type
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import { type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
|
|
3
|
+
interface Props {
|
|
2
4
|
checked?: boolean;
|
|
5
|
+
/** Partly checked. Announced as aria-checked="mixed". */
|
|
6
|
+
indeterminate?: boolean;
|
|
3
7
|
label?: string;
|
|
8
|
+
/** Screen-reader-only label, for a checkbox in a table cell whose column header is the visible name. */
|
|
9
|
+
labelHidden?: boolean;
|
|
10
|
+
/** Secondary line under the label. */
|
|
11
|
+
description?: string;
|
|
4
12
|
hint?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
/** Drawn before the label. */
|
|
15
|
+
icon?: Component<{
|
|
16
|
+
size?: number;
|
|
17
|
+
class?: string;
|
|
18
|
+
}>;
|
|
19
|
+
size?: ChoiceSize;
|
|
20
|
+
/** 'card' draws a bordered option whose whole surface is the target. */
|
|
21
|
+
variant?: ChoiceVariant;
|
|
5
22
|
required?: boolean;
|
|
6
23
|
disabled?: boolean;
|
|
7
24
|
id?: string;
|
|
@@ -9,7 +26,7 @@ type $$ComponentProps = {
|
|
|
9
26
|
value?: string;
|
|
10
27
|
class?: string;
|
|
11
28
|
onchange?: (checked: boolean) => void;
|
|
12
|
-
}
|
|
13
|
-
declare const Checkbox:
|
|
29
|
+
}
|
|
30
|
+
declare const Checkbox: Component<Props, {}, "checked" | "indeterminate">;
|
|
14
31
|
type Checkbox = ReturnType<typeof Checkbox>;
|
|
15
32
|
export default Checkbox;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One option in a choice group.
|
|
6
|
+
*
|
|
7
|
+
* RadioGroup declares this interface in the same words, so a consumer who has
|
|
8
|
+
* written one option list can write the other without rereading the types. A
|
|
9
|
+
* colocated test compares the two declarations character for character,
|
|
10
|
+
* because the group and the single control had already drifted apart once.
|
|
11
|
+
*/
|
|
12
|
+
export interface ChoiceOption {
|
|
13
|
+
/** The submitted value, and the key the option is rendered under. */
|
|
14
|
+
value: string;
|
|
15
|
+
/** Names this one choice. The group is named by the legend instead. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** A second line under the label, carrying what the label leaves out. */
|
|
18
|
+
description?: string;
|
|
19
|
+
/** A lucide component. The group sizes it, so the caller does not guess. */
|
|
20
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
21
|
+
/** Blocks this option alone. A disabled group blocks every option. */
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<script lang="ts">
|
|
27
|
+
import {
|
|
28
|
+
CHOICE_GROUP,
|
|
29
|
+
choiceGroupList,
|
|
30
|
+
type ChoiceOrientation,
|
|
31
|
+
type ChoiceSize,
|
|
32
|
+
type ChoiceVariant,
|
|
33
|
+
} from '../internal/choice.js';
|
|
34
|
+
import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL, describedBy } from '../internal/field.js';
|
|
35
|
+
import Checkbox from './Checkbox.svelte';
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
/** The chosen values, ordered by `options` rather than by click order. */
|
|
39
|
+
value?: string[];
|
|
40
|
+
options: ChoiceOption[];
|
|
41
|
+
/**
|
|
42
|
+
* The group's accessible name, rendered as a legend. Required because an
|
|
43
|
+
* unnamed group announces as a bare set of controls, which leaves a reader
|
|
44
|
+
* to infer what the set is for from the options alone.
|
|
45
|
+
*/
|
|
46
|
+
label: string;
|
|
47
|
+
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
48
|
+
labelHidden?: boolean;
|
|
49
|
+
hint?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
orientation?: ChoiceOrientation;
|
|
53
|
+
size?: ChoiceSize;
|
|
54
|
+
variant?: ChoiceVariant;
|
|
55
|
+
required?: boolean;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
class?: string;
|
|
58
|
+
onchange?: (value: string[]) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let {
|
|
62
|
+
value = $bindable([]),
|
|
63
|
+
options,
|
|
64
|
+
label,
|
|
65
|
+
labelHidden = false,
|
|
66
|
+
hint = undefined,
|
|
67
|
+
error = undefined,
|
|
68
|
+
name = undefined,
|
|
69
|
+
orientation = 'vertical',
|
|
70
|
+
size = 'md',
|
|
71
|
+
variant = 'inline',
|
|
72
|
+
required = false,
|
|
73
|
+
disabled = false,
|
|
74
|
+
class: cls = '',
|
|
75
|
+
onchange = undefined,
|
|
76
|
+
}: Props = $props();
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* The instance id, and through it the shared name, comes from $props.id().
|
|
80
|
+
* RadioGroup built its name from Math.random(), which produces one value on
|
|
81
|
+
* the server and a different one on hydration, so every id and every name
|
|
82
|
+
* derived from it changed under the client on first paint.
|
|
83
|
+
*/
|
|
84
|
+
const uid = $props.id();
|
|
85
|
+
const groupName = $derived(name ?? uid);
|
|
86
|
+
|
|
87
|
+
const chosen = $derived(new Set(value));
|
|
88
|
+
|
|
89
|
+
function toggle(option: string, on: boolean) {
|
|
90
|
+
const next = new Set(value);
|
|
91
|
+
if (on) {
|
|
92
|
+
next.add(option);
|
|
93
|
+
} else {
|
|
94
|
+
next.delete(option);
|
|
95
|
+
}
|
|
96
|
+
// Filtered out of `options` rather than appended on click, so ticking C
|
|
97
|
+
// and then A submits the same value as ticking A and then C.
|
|
98
|
+
value = options.filter((o) => next.has(o.value)).map((o) => o.value);
|
|
99
|
+
onchange?.(value);
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<!--
|
|
104
|
+
No aria-invalid here, where RadioGroup carries one. ARIA supports the
|
|
105
|
+
attribute on radiogroup and not on group, which is the role a fieldset of
|
|
106
|
+
checkboxes has and the only role that fits it, so the kit's a11y gate rejects
|
|
107
|
+
it outright. The error still reaches a reader through aria-describedby, which
|
|
108
|
+
is global and points at the message paragraph below.
|
|
109
|
+
|
|
110
|
+
aria-required is gone for the same reason and is not replaced by `required` on
|
|
111
|
+
each box, which would demand every option be ticked instead of one of them.
|
|
112
|
+
The requirement is in the group's name instead: a group's name comes from its
|
|
113
|
+
legend, so the legend says "(required)" and the set announces as "Interests
|
|
114
|
+
(required), group". Naming a group that way is accurate, where naming a single
|
|
115
|
+
control that way puts a word into what a voice user has to speak at it. The
|
|
116
|
+
asterisk stays decoration, and the hint, when the caller writes one, says what
|
|
117
|
+
is required and reaches the reader through aria-describedby.
|
|
118
|
+
-->
|
|
119
|
+
<fieldset class="{CHOICE_GROUP} {cls}" {disabled} aria-describedby={describedBy(uid, error, hint)}>
|
|
120
|
+
<!--
|
|
121
|
+
The legend stays a legend when it is hidden. Swapping it for an aria-label
|
|
122
|
+
on the fieldset would name the group and drop it out of the reading order,
|
|
123
|
+
so a reader moving through the page would meet the options with nothing
|
|
124
|
+
ahead of them saying what the set is for.
|
|
125
|
+
-->
|
|
126
|
+
<legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
127
|
+
{label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span><span
|
|
128
|
+
class="sr-only"
|
|
129
|
+
>
|
|
130
|
+
(required)</span
|
|
131
|
+
>{/if}
|
|
132
|
+
</legend>
|
|
133
|
+
|
|
134
|
+
<div class={choiceGroupList(orientation)}>
|
|
135
|
+
{#each options as option (option.value)}
|
|
136
|
+
<!--
|
|
137
|
+
The option renders through Checkbox rather than through a copy of it.
|
|
138
|
+
A group that paints its own box is how RadioGroup came to rest on
|
|
139
|
+
border-line and to keep its focus ring inside the selected branch while
|
|
140
|
+
the single control had been fixed for both.
|
|
141
|
+
-->
|
|
142
|
+
<Checkbox
|
|
143
|
+
checked={chosen.has(option.value)}
|
|
144
|
+
value={option.value}
|
|
145
|
+
label={option.label}
|
|
146
|
+
description={option.description}
|
|
147
|
+
icon={option.icon}
|
|
148
|
+
name={groupName}
|
|
149
|
+
{size}
|
|
150
|
+
{variant}
|
|
151
|
+
disabled={disabled || option.disabled === true}
|
|
152
|
+
onchange={(on) => toggle(option.value, on)}
|
|
153
|
+
/>
|
|
154
|
+
{/each}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{#if error}
|
|
158
|
+
<p id="{uid}-error" class={FIELD_ERROR}>{error}</p>
|
|
159
|
+
{:else if hint}
|
|
160
|
+
<p id="{uid}-hint" class={FIELD_HINT}>{hint}</p>
|
|
161
|
+
{/if}
|
|
162
|
+
</fieldset>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* One option in a choice group.
|
|
4
|
+
*
|
|
5
|
+
* RadioGroup declares this interface in the same words, so a consumer who has
|
|
6
|
+
* written one option list can write the other without rereading the types. A
|
|
7
|
+
* colocated test compares the two declarations character for character,
|
|
8
|
+
* because the group and the single control had already drifted apart once.
|
|
9
|
+
*/
|
|
10
|
+
export interface ChoiceOption {
|
|
11
|
+
/** The submitted value, and the key the option is rendered under. */
|
|
12
|
+
value: string;
|
|
13
|
+
/** Names this one choice. The group is named by the legend instead. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** A second line under the label, carrying what the label leaves out. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** A lucide component. The group sizes it, so the caller does not guess. */
|
|
18
|
+
icon?: Component<{
|
|
19
|
+
size?: number;
|
|
20
|
+
class?: string;
|
|
21
|
+
}>;
|
|
22
|
+
/** Blocks this option alone. A disabled group blocks every option. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
import { type ChoiceOrientation, type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
|
|
26
|
+
interface Props {
|
|
27
|
+
/** The chosen values, ordered by `options` rather than by click order. */
|
|
28
|
+
value?: string[];
|
|
29
|
+
options: ChoiceOption[];
|
|
30
|
+
/**
|
|
31
|
+
* The group's accessible name, rendered as a legend. Required because an
|
|
32
|
+
* unnamed group announces as a bare set of controls, which leaves a reader
|
|
33
|
+
* to infer what the set is for from the options alone.
|
|
34
|
+
*/
|
|
35
|
+
label: string;
|
|
36
|
+
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
37
|
+
labelHidden?: boolean;
|
|
38
|
+
hint?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
orientation?: ChoiceOrientation;
|
|
42
|
+
size?: ChoiceSize;
|
|
43
|
+
variant?: ChoiceVariant;
|
|
44
|
+
required?: boolean;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
class?: string;
|
|
47
|
+
onchange?: (value: string[]) => void;
|
|
48
|
+
}
|
|
49
|
+
declare const CheckboxGroup: Component<Props, {}, "value">;
|
|
50
|
+
type CheckboxGroup = ReturnType<typeof CheckboxGroup>;
|
|
51
|
+
export default CheckboxGroup;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One disclosure that owns nothing but itself: an advanced-options block, a
|
|
4
|
+
* long log line, a stack trace folded under an error.
|
|
5
|
+
*
|
|
6
|
+
* Not the native `details` element. Its open state cannot be animated, so the
|
|
7
|
+
* content snaps in and out in a single frame, and it cannot be driven from
|
|
8
|
+
* outside: a page that wants to open the block from a deep link or a search
|
|
9
|
+
* hit has no way in. `open` here is bindable, so the page and the trigger
|
|
10
|
+
* share one value.
|
|
11
|
+
*
|
|
12
|
+
* Accordion is the component for a set of these. It decides which of its
|
|
13
|
+
* items is open, and an item asks it; this decides nothing beyond itself.
|
|
14
|
+
*/
|
|
15
|
+
import type { Component, Snippet } from 'svelte';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
open?: boolean;
|
|
19
|
+
/** The clickable summary. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Drawn before the label. */
|
|
22
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
23
|
+
/** A count or status beside the label. */
|
|
24
|
+
badge?: string | number;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
class?: string;
|
|
27
|
+
children: Snippet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
open = $bindable(false),
|
|
32
|
+
label,
|
|
33
|
+
icon = undefined,
|
|
34
|
+
badge = undefined,
|
|
35
|
+
disabled = false,
|
|
36
|
+
class: klass = '',
|
|
37
|
+
children,
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
// $props.id() and not a random string: a random id differs between the server
|
|
41
|
+
// render and hydration, so aria-controls points at nothing for the first
|
|
42
|
+
// paint and the two halves of the disclosure are wired to different names.
|
|
43
|
+
const uid = $props.id();
|
|
44
|
+
const triggerId = `${uid}-trigger`;
|
|
45
|
+
const panelId = `${uid}-panel`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The disabled attribute stops a user press and not a dispatched one. A click
|
|
49
|
+
* sent from script reaches the handler on a disabled button, so the state
|
|
50
|
+
* change is guarded here as well as on the element.
|
|
51
|
+
*/
|
|
52
|
+
function toggle() {
|
|
53
|
+
if (disabled) return;
|
|
54
|
+
open = !open;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<div class={klass}>
|
|
59
|
+
<!-- The focus ring is inset. The trigger spans the full width of whatever
|
|
60
|
+
holds it, so the global 2px outset outline is cropped by the first
|
|
61
|
+
ancestor that clips.
|
|
62
|
+
The hover background is dropped rather than overridden when the trigger
|
|
63
|
+
is disabled: :hover still matches a disabled button, and a second
|
|
64
|
+
background utility on the same element would resolve by the order
|
|
65
|
+
Tailwind emits it rather than the order it was written. -->
|
|
66
|
+
<button
|
|
67
|
+
id={triggerId}
|
|
68
|
+
type="button"
|
|
69
|
+
{disabled}
|
|
70
|
+
aria-expanded={open}
|
|
71
|
+
aria-controls={panelId}
|
|
72
|
+
onclick={toggle}
|
|
73
|
+
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium
|
|
74
|
+
text-fg transition-colors duration-150 outline-none focus-visible:ring-2
|
|
75
|
+
focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed
|
|
76
|
+
disabled:opacity-50 {disabled ? '' : 'hover:bg-surface-2'}"
|
|
77
|
+
>
|
|
78
|
+
<!-- The chevron points along the axis the content moves on: right while the
|
|
79
|
+
block is closed, down once it is open. -->
|
|
80
|
+
<svg
|
|
81
|
+
width="16"
|
|
82
|
+
height="16"
|
|
83
|
+
viewBox="0 0 24 24"
|
|
84
|
+
fill="none"
|
|
85
|
+
stroke="currentColor"
|
|
86
|
+
stroke-width="2"
|
|
87
|
+
stroke-linecap="round"
|
|
88
|
+
stroke-linejoin="round"
|
|
89
|
+
class="shrink-0 text-faint transition-transform duration-200 ease-out {open
|
|
90
|
+
? ''
|
|
91
|
+
: '-rotate-90'}"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<path d="M6 9l6 6 6-6" />
|
|
95
|
+
</svg>
|
|
96
|
+
|
|
97
|
+
{#if icon}
|
|
98
|
+
{@const Icon = icon}
|
|
99
|
+
<Icon size={16} class="shrink-0 text-faint" />
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
<span class="truncate">{label}</span>
|
|
103
|
+
|
|
104
|
+
{#if badge !== undefined}
|
|
105
|
+
<span class="ml-auto shrink-0 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-muted">
|
|
106
|
+
{badge}
|
|
107
|
+
</span>
|
|
108
|
+
{/if}
|
|
109
|
+
</button>
|
|
110
|
+
|
|
111
|
+
<!--
|
|
112
|
+
A block opens to the height of its own content, which no fixed value knows.
|
|
113
|
+
Animating grid-template-rows from 0fr to 1fr resolves to that height in CSS
|
|
114
|
+
alone: no measuring, no reflow per frame, and nothing to correct when the
|
|
115
|
+
content changes. The inner element carries the overflow so the content is
|
|
116
|
+
clipped rather than spilling while the row grows.
|
|
117
|
+
-->
|
|
118
|
+
<div
|
|
119
|
+
class="grid transition-[grid-template-rows] duration-200 ease-out {open
|
|
120
|
+
? 'grid-rows-[1fr]'
|
|
121
|
+
: 'grid-rows-[0fr]'}"
|
|
122
|
+
>
|
|
123
|
+
<div class="overflow-hidden">
|
|
124
|
+
<!--
|
|
125
|
+
inert, not height alone. The content stays mounted so the row can
|
|
126
|
+
animate to its height, and a subtree that is only zero pixels tall is
|
|
127
|
+
still in the accessibility tree and still in the tab order: Tab lands on
|
|
128
|
+
a control the reader cannot see and the page appears to lose focus.
|
|
129
|
+
inert is what takes the whole subtree out of both.
|
|
130
|
+
-->
|
|
131
|
+
<div
|
|
132
|
+
id={panelId}
|
|
133
|
+
role="region"
|
|
134
|
+
aria-labelledby={triggerId}
|
|
135
|
+
inert={!open}
|
|
136
|
+
class="px-2 pt-2 text-sm"
|
|
137
|
+
>
|
|
138
|
+
{@render children()}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One disclosure that owns nothing but itself: an advanced-options block, a
|
|
3
|
+
* long log line, a stack trace folded under an error.
|
|
4
|
+
*
|
|
5
|
+
* Not the native `details` element. Its open state cannot be animated, so the
|
|
6
|
+
* content snaps in and out in a single frame, and it cannot be driven from
|
|
7
|
+
* outside: a page that wants to open the block from a deep link or a search
|
|
8
|
+
* hit has no way in. `open` here is bindable, so the page and the trigger
|
|
9
|
+
* share one value.
|
|
10
|
+
*
|
|
11
|
+
* Accordion is the component for a set of these. It decides which of its
|
|
12
|
+
* items is open, and an item asks it; this decides nothing beyond itself.
|
|
13
|
+
*/
|
|
14
|
+
import type { Component, Snippet } from 'svelte';
|
|
15
|
+
interface Props {
|
|
16
|
+
open?: boolean;
|
|
17
|
+
/** The clickable summary. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Drawn before the label. */
|
|
20
|
+
icon?: Component<{
|
|
21
|
+
size?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}>;
|
|
24
|
+
/** A count or status beside the label. */
|
|
25
|
+
badge?: string | number;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
class?: string;
|
|
28
|
+
children: Snippet;
|
|
29
|
+
}
|
|
30
|
+
declare const Collapsible: Component<Props, {}, "open">;
|
|
31
|
+
type Collapsible = ReturnType<typeof Collapsible>;
|
|
32
|
+
export default Collapsible;
|