@lyeve-labs/ui-kit 0.11.2 → 0.13.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 +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +61 -3
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -59
- 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/Drawer.svelte +15 -4
- 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/Modal.svelte +25 -8
- 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 +58 -17
- 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 -32
- 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 +259 -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/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- 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 +31 -0
- package/dist/internal/field.js +42 -1
- 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/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -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 +66 -25
- package/package.json +4 -2
- package/src/lib/styles/theme.css +66 -25
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import { type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
|
|
1
3
|
interface Props {
|
|
2
4
|
value: string;
|
|
3
5
|
group?: string;
|
|
4
6
|
label?: string;
|
|
7
|
+
/** Screen-reader-only label, for a radio in a table cell whose column header is the visible name. */
|
|
8
|
+
labelHidden?: boolean;
|
|
9
|
+
/** Secondary line under the label. */
|
|
10
|
+
description?: string;
|
|
5
11
|
hint?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
/** Drawn before the label. */
|
|
14
|
+
icon?: Component<{
|
|
15
|
+
size?: number;
|
|
16
|
+
class?: string;
|
|
17
|
+
}>;
|
|
18
|
+
size?: ChoiceSize;
|
|
19
|
+
/** 'card' draws a bordered option whose whole surface is the target. */
|
|
20
|
+
variant?: ChoiceVariant;
|
|
6
21
|
required?: boolean;
|
|
7
22
|
disabled?: boolean;
|
|
8
23
|
id?: string;
|
|
@@ -10,6 +25,6 @@ interface Props {
|
|
|
10
25
|
class?: string;
|
|
11
26
|
onchange?: (value: string) => void;
|
|
12
27
|
}
|
|
13
|
-
declare const Radio:
|
|
28
|
+
declare const Radio: Component<Props, {}, "group">;
|
|
14
29
|
type Radio = ReturnType<typeof Radio>;
|
|
15
30
|
export default Radio;
|
|
@@ -1,97 +1,144 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One option in a choice group.
|
|
6
|
+
*
|
|
7
|
+
* CheckboxGroup declares this interface in the same words, so a consumer who
|
|
8
|
+
* has written one option list can write the other without rereading the
|
|
9
|
+
* types. A colocated test compares the two declarations character for
|
|
10
|
+
* character, because the group and the single control had already drifted
|
|
11
|
+
* apart once.
|
|
12
|
+
*/
|
|
13
|
+
export interface ChoiceOption {
|
|
14
|
+
/** The submitted value, and the key the option is rendered under. */
|
|
6
15
|
value: string;
|
|
16
|
+
/** Names this one choice. The group is named by the legend instead. */
|
|
7
17
|
label: string;
|
|
18
|
+
/** A second line under the label, carrying what the label leaves out. */
|
|
19
|
+
description?: string;
|
|
20
|
+
/** A lucide component. The group sizes it, so the caller does not guess. */
|
|
21
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
22
|
+
/** Blocks this option alone. A disabled group blocks every option. */
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import {
|
|
29
|
+
CHOICE_GROUP,
|
|
30
|
+
choiceGroupList,
|
|
31
|
+
type ChoiceOrientation,
|
|
32
|
+
type ChoiceSize,
|
|
33
|
+
type ChoiceVariant,
|
|
34
|
+
} from '../internal/choice.js';
|
|
35
|
+
import { FIELD_ERROR, FIELD_HINT, FIELD_LABEL, describedBy } from '../internal/field.js';
|
|
36
|
+
import Radio from './Radio.svelte';
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** The selected value. One of the option values, or empty for none. */
|
|
40
|
+
value?: string;
|
|
41
|
+
options: ChoiceOption[];
|
|
42
|
+
/**
|
|
43
|
+
* The group's accessible name, rendered as a legend. Required because an
|
|
44
|
+
* unnamed group announces as a bare set of controls, which leaves a reader
|
|
45
|
+
* to infer what the set is for from the options alone.
|
|
46
|
+
*/
|
|
47
|
+
label: string;
|
|
48
|
+
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
49
|
+
labelHidden?: boolean;
|
|
8
50
|
hint?: string;
|
|
51
|
+
error?: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
orientation?: ChoiceOrientation;
|
|
54
|
+
size?: ChoiceSize;
|
|
55
|
+
variant?: ChoiceVariant;
|
|
56
|
+
required?: boolean;
|
|
9
57
|
disabled?: boolean;
|
|
58
|
+
class?: string;
|
|
59
|
+
onchange?: (value: string) => void;
|
|
10
60
|
}
|
|
11
61
|
|
|
12
62
|
let {
|
|
13
63
|
value = $bindable(''),
|
|
14
64
|
options,
|
|
15
|
-
|
|
16
|
-
|
|
65
|
+
label,
|
|
66
|
+
labelHidden = false,
|
|
17
67
|
hint = undefined,
|
|
18
68
|
error = undefined,
|
|
69
|
+
name = undefined,
|
|
70
|
+
orientation = 'vertical',
|
|
71
|
+
size = 'md',
|
|
72
|
+
variant = 'inline',
|
|
19
73
|
required = false,
|
|
20
74
|
disabled = false,
|
|
21
|
-
orientation = 'vertical',
|
|
22
75
|
class: cls = '',
|
|
23
76
|
onchange = undefined,
|
|
24
|
-
}:
|
|
25
|
-
value?: string;
|
|
26
|
-
options: Option[];
|
|
27
|
-
name?: string;
|
|
28
|
-
label?: string;
|
|
29
|
-
hint?: string;
|
|
30
|
-
error?: string;
|
|
31
|
-
required?: boolean;
|
|
32
|
-
disabled?: boolean;
|
|
33
|
-
orientation?: 'vertical' | 'horizontal';
|
|
34
|
-
class?: string;
|
|
35
|
-
onchange?: (value: string) => void;
|
|
36
|
-
} = $props();
|
|
77
|
+
}: Props = $props();
|
|
37
78
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
/*
|
|
80
|
+
* The instance id, and through it the shared name, comes from $props.id().
|
|
81
|
+
* This group built its name from `rg-${Math.random()}`, which produces one
|
|
82
|
+
* value on the server and a different one on hydration, so every id and every
|
|
83
|
+
* name derived from it changed under the client on first paint. Two radios
|
|
84
|
+
* that disagree about their name are two groups, and selecting one no longer
|
|
85
|
+
* clears the other.
|
|
86
|
+
*/
|
|
87
|
+
const uid = $props.id();
|
|
88
|
+
const groupName = $derived(name ?? uid);
|
|
46
89
|
</script>
|
|
47
90
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
91
|
+
<!--
|
|
92
|
+
role="radiogroup" over the fieldset's implicit "group". A set of radios is a
|
|
93
|
+
radiogroup, and that is the role ARIA lets carry aria-invalid and
|
|
94
|
+
aria-required, so the group can report its own validity rather than leaving a
|
|
95
|
+
reader to find the message paragraph on their own.
|
|
96
|
+
-->
|
|
97
|
+
<fieldset
|
|
98
|
+
class="{CHOICE_GROUP} {cls}"
|
|
99
|
+
role="radiogroup"
|
|
100
|
+
{disabled}
|
|
101
|
+
aria-invalid={error ? 'true' : undefined}
|
|
102
|
+
aria-required={required ? 'true' : undefined}
|
|
103
|
+
aria-describedby={describedBy(uid, error, hint)}
|
|
104
|
+
>
|
|
105
|
+
<!--
|
|
106
|
+
The legend stays a legend when it is hidden. Swapping it for an aria-label
|
|
107
|
+
on the fieldset would name the group and drop it out of the reading order,
|
|
108
|
+
so a reader moving through the page would meet the options with nothing
|
|
109
|
+
ahead of them saying what the set is for.
|
|
110
|
+
-->
|
|
111
|
+
<legend class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
|
|
112
|
+
{label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
|
|
113
|
+
</legend>
|
|
54
114
|
|
|
55
|
-
<div
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
? 'border-brand bg-surface-2'
|
|
77
|
-
: 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
|
|
78
|
-
>
|
|
79
|
-
{#if value === opt.value}
|
|
80
|
-
<span class="h-2 w-2 rounded-full bg-brand"></span>
|
|
81
|
-
{/if}
|
|
82
|
-
</span>
|
|
83
|
-
</span>
|
|
84
|
-
<span class="flex flex-col gap-0.5">
|
|
85
|
-
<span class="text-sm text-fg">{opt.label}</span>
|
|
86
|
-
{#if opt.hint}<span class={FIELD_HINT}>{opt.hint}</span>{/if}
|
|
87
|
-
</span>
|
|
88
|
-
</label>
|
|
115
|
+
<div class={choiceGroupList(orientation)}>
|
|
116
|
+
{#each options as option (option.value)}
|
|
117
|
+
<!--
|
|
118
|
+
The option renders through Radio rather than through a copy of it. The
|
|
119
|
+
copy is what let this group rest its circle on border-line, at 1.25:1,
|
|
120
|
+
and keep its focus ring inside the selected branch, so choosing an
|
|
121
|
+
option deleted the only indicator a keyboard user had. Radio had been
|
|
122
|
+
fixed for both defects while the group still carried them.
|
|
123
|
+
-->
|
|
124
|
+
<Radio
|
|
125
|
+
bind:group={value}
|
|
126
|
+
value={option.value}
|
|
127
|
+
label={option.label}
|
|
128
|
+
description={option.description}
|
|
129
|
+
icon={option.icon}
|
|
130
|
+
name={groupName}
|
|
131
|
+
{size}
|
|
132
|
+
{variant}
|
|
133
|
+
disabled={disabled || option.disabled === true}
|
|
134
|
+
{onchange}
|
|
135
|
+
/>
|
|
89
136
|
{/each}
|
|
90
137
|
</div>
|
|
91
138
|
|
|
92
139
|
{#if error}
|
|
93
|
-
<p class={FIELD_ERROR}>{error}</p>
|
|
140
|
+
<p id="{uid}-error" class={FIELD_ERROR}>{error}</p>
|
|
94
141
|
{:else if hint}
|
|
95
|
-
<p class={FIELD_HINT}>{hint}</p>
|
|
142
|
+
<p id="{uid}-hint" class={FIELD_HINT}>{hint}</p>
|
|
96
143
|
{/if}
|
|
97
144
|
</fieldset>
|
|
@@ -1,22 +1,52 @@
|
|
|
1
|
-
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* One option in a choice group.
|
|
4
|
+
*
|
|
5
|
+
* CheckboxGroup declares this interface in the same words, so a consumer who
|
|
6
|
+
* has written one option list can write the other without rereading the
|
|
7
|
+
* types. A colocated test compares the two declarations character for
|
|
8
|
+
* character, because the group and the single control had already drifted
|
|
9
|
+
* apart once.
|
|
10
|
+
*/
|
|
11
|
+
export interface ChoiceOption {
|
|
12
|
+
/** The submitted value, and the key the option is rendered under. */
|
|
2
13
|
value: string;
|
|
14
|
+
/** Names this one choice. The group is named by the legend instead. */
|
|
3
15
|
label: string;
|
|
4
|
-
|
|
16
|
+
/** A second line under the label, carrying what the label leaves out. */
|
|
17
|
+
description?: string;
|
|
18
|
+
/** A lucide component. The group sizes it, so the caller does not guess. */
|
|
19
|
+
icon?: Component<{
|
|
20
|
+
size?: number;
|
|
21
|
+
class?: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** Blocks this option alone. A disabled group blocks every option. */
|
|
5
24
|
disabled?: boolean;
|
|
6
25
|
}
|
|
7
|
-
type
|
|
26
|
+
import { type ChoiceOrientation, type ChoiceSize, type ChoiceVariant } from '../internal/choice.js';
|
|
27
|
+
interface Props {
|
|
28
|
+
/** The selected value. One of the option values, or empty for none. */
|
|
8
29
|
value?: string;
|
|
9
|
-
options:
|
|
10
|
-
|
|
11
|
-
|
|
30
|
+
options: ChoiceOption[];
|
|
31
|
+
/**
|
|
32
|
+
* The group's accessible name, rendered as a legend. Required because an
|
|
33
|
+
* unnamed group announces as a bare set of controls, which leaves a reader
|
|
34
|
+
* to infer what the set is for from the options alone.
|
|
35
|
+
*/
|
|
36
|
+
label: string;
|
|
37
|
+
/** Keeps the legend for a screen reader and takes it off the screen. */
|
|
38
|
+
labelHidden?: boolean;
|
|
12
39
|
hint?: string;
|
|
13
40
|
error?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
orientation?: ChoiceOrientation;
|
|
43
|
+
size?: ChoiceSize;
|
|
44
|
+
variant?: ChoiceVariant;
|
|
14
45
|
required?: boolean;
|
|
15
46
|
disabled?: boolean;
|
|
16
|
-
orientation?: 'vertical' | 'horizontal';
|
|
17
47
|
class?: string;
|
|
18
48
|
onchange?: (value: string) => void;
|
|
19
|
-
}
|
|
20
|
-
declare const RadioGroup:
|
|
49
|
+
}
|
|
50
|
+
declare const RadioGroup: Component<Props, {}, "value">;
|
|
21
51
|
type RadioGroup = ReturnType<typeof RadioGroup>;
|
|
22
52
|
export default RadioGroup;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A heading for a section of a page, with an optional row of controls beside
|
|
4
|
+
* it.
|
|
5
|
+
*
|
|
6
|
+
* Fourteen distinct class strings serve this role across one app, so two
|
|
7
|
+
* sections on the same page render at different sizes and weights, and some
|
|
8
|
+
* of them are a styled `div` that no heading query can find. Taking the level
|
|
9
|
+
* rather than a free-form class means the type treatment cannot disagree with
|
|
10
|
+
* the element, and the element is always a real heading.
|
|
11
|
+
*/
|
|
12
|
+
import type { Snippet } from 'svelte';
|
|
13
|
+
import { sectionHeading } from '../internal/layout.js';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** 2 under a page title, 3 inside a card. */
|
|
17
|
+
level?: 2 | 3;
|
|
18
|
+
actions?: Snippet;
|
|
19
|
+
class?: string;
|
|
20
|
+
children: Snippet;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { level = 2, actions, class: klass = '', children }: Props = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<div class="flex flex-wrap items-center justify-between gap-4 {klass}">
|
|
27
|
+
<!-- Two branches rather than one dynamic element: the level is a document
|
|
28
|
+
structure decision, and a reader of this file should be able to see both
|
|
29
|
+
headings it can produce. -->
|
|
30
|
+
{#if level === 2}
|
|
31
|
+
<h2 class={sectionHeading(2)}>{@render children()}</h2>
|
|
32
|
+
{:else}
|
|
33
|
+
<h3 class={sectionHeading(3)}>{@render children()}</h3>
|
|
34
|
+
{/if}
|
|
35
|
+
|
|
36
|
+
{#if actions}
|
|
37
|
+
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
38
|
+
{/if}
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A heading for a section of a page, with an optional row of controls beside
|
|
3
|
+
* it.
|
|
4
|
+
*
|
|
5
|
+
* Fourteen distinct class strings serve this role across one app, so two
|
|
6
|
+
* sections on the same page render at different sizes and weights, and some
|
|
7
|
+
* of them are a styled `div` that no heading query can find. Taking the level
|
|
8
|
+
* rather than a free-form class means the type treatment cannot disagree with
|
|
9
|
+
* the element, and the element is always a real heading.
|
|
10
|
+
*/
|
|
11
|
+
import type { Snippet } from 'svelte';
|
|
12
|
+
interface Props {
|
|
13
|
+
/** 2 under a page title, 3 inside a card. */
|
|
14
|
+
level?: 2 | 3;
|
|
15
|
+
actions?: Snippet;
|
|
16
|
+
class?: string;
|
|
17
|
+
children: Snippet;
|
|
18
|
+
}
|
|
19
|
+
declare const SectionHeading: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type SectionHeading = ReturnType<typeof SectionHeading>;
|
|
21
|
+
export default SectionHeading;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/** One segment of the row. */
|
|
5
|
+
export interface SegmentOption<T extends string = string> {
|
|
6
|
+
/** The chosen value, and the key the segment is rendered under. */
|
|
7
|
+
value: T;
|
|
8
|
+
/** Names this one segment. The group is named by `label`. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** A lucide component. The control sizes it, so the caller does not guess. */
|
|
11
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts" generics="T extends string">
|
|
16
|
+
import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
|
|
17
|
+
|
|
18
|
+
type Size = 'sm' | 'md';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
/** The chosen value. One of the option values. */
|
|
22
|
+
value: T;
|
|
23
|
+
options: SegmentOption<T>[];
|
|
24
|
+
/**
|
|
25
|
+
* The group's accessible name. Required because an unnamed group announces
|
|
26
|
+
* as a bare set of controls, which leaves a reader to work out what the set
|
|
27
|
+
* is for from the segments alone.
|
|
28
|
+
*/
|
|
29
|
+
label: string;
|
|
30
|
+
/** Drops the caption above the row. The group keeps its accessible name. */
|
|
31
|
+
labelHidden?: boolean;
|
|
32
|
+
/** Submits the value with the surrounding form, through a hidden input. */
|
|
33
|
+
name?: string;
|
|
34
|
+
size?: Size;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
class?: string;
|
|
37
|
+
onchange?: (value: T) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
value = $bindable(),
|
|
42
|
+
options,
|
|
43
|
+
label,
|
|
44
|
+
labelHidden = false,
|
|
45
|
+
name = undefined,
|
|
46
|
+
size = 'md',
|
|
47
|
+
disabled = false,
|
|
48
|
+
class: klass = '',
|
|
49
|
+
onchange = undefined,
|
|
50
|
+
}: Props = $props();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The row height comes from the control token, so a segmented control and an
|
|
54
|
+
* Input in the same row line up. The segments stretch inside it rather than
|
|
55
|
+
* setting their own height, which would add the row's padding and border on
|
|
56
|
+
* top and stand the control two pixels proud of everything beside it.
|
|
57
|
+
*/
|
|
58
|
+
const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
|
|
59
|
+
sm: { row: 'h-8', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
|
|
60
|
+
md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let rootEl: HTMLDivElement | undefined = $state();
|
|
64
|
+
|
|
65
|
+
const selectedIndex = $derived(options.findIndex((o) => o.value === value));
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* The group holds ONE tab stop, which is the whole reason this is not a row
|
|
69
|
+
* of buttons: a picker of five themes cost five presses of Tab to step over.
|
|
70
|
+
*
|
|
71
|
+
* A value matching no option would leave every segment at tabindex -1 and
|
|
72
|
+
* drop the control out of the tab order entirely, so the first segment holds
|
|
73
|
+
* the stop until something is chosen.
|
|
74
|
+
*/
|
|
75
|
+
const tabStop = $derived(selectedIndex === -1 ? 0 : selectedIndex);
|
|
76
|
+
|
|
77
|
+
function segmentAt(index: number): HTMLElement | undefined {
|
|
78
|
+
return rootEl?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function choose(index: number) {
|
|
82
|
+
// Disabled is enforced here as well as on each button. The native attribute
|
|
83
|
+
// is what stops a real press, and it is the only thing that does, so a
|
|
84
|
+
// change arriving any other way would still move the bound value with
|
|
85
|
+
// nothing on screen to say so.
|
|
86
|
+
if (disabled) return;
|
|
87
|
+
const next = options[index];
|
|
88
|
+
if (!next) return;
|
|
89
|
+
if (next.value !== value) {
|
|
90
|
+
value = next.value;
|
|
91
|
+
onchange?.(next.value);
|
|
92
|
+
}
|
|
93
|
+
// The roving stop follows the pointer too. A press does not focus a button
|
|
94
|
+
// in every browser, so without this a later Tab re-enters the group on
|
|
95
|
+
// whichever segment the keyboard last left.
|
|
96
|
+
segmentAt(index)?.focus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The radio keyboard pattern: the arrows move the selection and wrap, Home
|
|
101
|
+
* and End go to the ends, and focus travels with the choice.
|
|
102
|
+
*/
|
|
103
|
+
function onSegmentKeydown(e: KeyboardEvent, index: number) {
|
|
104
|
+
const last = options.length - 1;
|
|
105
|
+
let next: number;
|
|
106
|
+
switch (e.key) {
|
|
107
|
+
case 'ArrowRight':
|
|
108
|
+
case 'ArrowDown':
|
|
109
|
+
next = index === last ? 0 : index + 1;
|
|
110
|
+
break;
|
|
111
|
+
case 'ArrowLeft':
|
|
112
|
+
case 'ArrowUp':
|
|
113
|
+
next = index === 0 ? last : index - 1;
|
|
114
|
+
break;
|
|
115
|
+
case 'Home':
|
|
116
|
+
next = 0;
|
|
117
|
+
break;
|
|
118
|
+
case 'End':
|
|
119
|
+
next = last;
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// The arrows scroll the page, and Home and End jump it to the ends.
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
choose(next);
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<div class="{FIELD_WRAP} items-start {klass}">
|
|
131
|
+
{#if !labelHidden}
|
|
132
|
+
<!-- A caption, not a label element. The group is named by aria-label, and a
|
|
133
|
+
label has nothing to point at here: role="radiogroup" is not a form
|
|
134
|
+
control that `for` can name. -->
|
|
135
|
+
<span class={FIELD_LABEL}>{label}</span>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
<div
|
|
139
|
+
bind:this={rootEl}
|
|
140
|
+
role="radiogroup"
|
|
141
|
+
aria-label={label}
|
|
142
|
+
class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
|
|
143
|
+
size
|
|
144
|
+
].row} {disabled ? 'opacity-50' : ''}"
|
|
145
|
+
>
|
|
146
|
+
{#each options as option, i (option.value)}
|
|
147
|
+
{@const selected = option.value === value}
|
|
148
|
+
{@const Icon = option.icon}
|
|
149
|
+
<!--
|
|
150
|
+
Each segment is a radio, and aria-checked is what a screen reader reads.
|
|
151
|
+
The theme picker this replaces was a row of plain buttons whose choice
|
|
152
|
+
was carried by a background colour alone, so a screen reader user was
|
|
153
|
+
told nothing and a colour-blind user saw nothing. The selected segment
|
|
154
|
+
also sits at a heavier weight, so the state survives a palette a reader
|
|
155
|
+
cannot separate.
|
|
156
|
+
-->
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
role="radio"
|
|
160
|
+
aria-checked={selected}
|
|
161
|
+
tabindex={i === tabStop ? 0 : -1}
|
|
162
|
+
{disabled}
|
|
163
|
+
onclick={() => choose(i)}
|
|
164
|
+
onkeydown={(e) => onSegmentKeydown(e, i)}
|
|
165
|
+
class="inline-flex items-center justify-center rounded-md outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed {SIZES[
|
|
166
|
+
size
|
|
167
|
+
].segment} {selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg'}"
|
|
168
|
+
>
|
|
169
|
+
{#if Icon}
|
|
170
|
+
<Icon size={SIZES[size].icon} class="shrink-0" />
|
|
171
|
+
{/if}
|
|
172
|
+
<span class="grid text-center">
|
|
173
|
+
<!-- The bold copy holds the width of the heaviest state in every
|
|
174
|
+
segment. Weight alone would resize the segment the selection
|
|
175
|
+
lands on, and the whole row would shuffle under the pointer as
|
|
176
|
+
the user moved along it. -->
|
|
177
|
+
<span class="invisible col-start-1 row-start-1 font-semibold" aria-hidden="true"
|
|
178
|
+
>{option.label}</span
|
|
179
|
+
>
|
|
180
|
+
<span class="col-start-1 row-start-1 {selected ? 'font-semibold' : 'font-normal'}"
|
|
181
|
+
>{option.label}</span
|
|
182
|
+
>
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
{/each}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{#if name}
|
|
189
|
+
<!-- A div with role="radiogroup" submits nothing. The value reaches a form
|
|
190
|
+
post through this and not through the segments, which are buttons and
|
|
191
|
+
would each post their own. -->
|
|
192
|
+
<input type="hidden" {name} {value} />
|
|
193
|
+
{/if}
|
|
194
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/** One segment of the row. */
|
|
3
|
+
export interface SegmentOption<T extends string = string> {
|
|
4
|
+
/** The chosen value, and the key the segment is rendered under. */
|
|
5
|
+
value: T;
|
|
6
|
+
/** Names this one segment. The group is named by `label`. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** A lucide component. The control sizes it, so the caller does not guess. */
|
|
9
|
+
icon?: Component<{
|
|
10
|
+
size?: number;
|
|
11
|
+
class?: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
declare function $$render<T extends string>(): {
|
|
15
|
+
props: {
|
|
16
|
+
/** The chosen value. One of the option values. */
|
|
17
|
+
value: T;
|
|
18
|
+
options: SegmentOption<T>[];
|
|
19
|
+
/**
|
|
20
|
+
* The group's accessible name. Required because an unnamed group announces
|
|
21
|
+
* as a bare set of controls, which leaves a reader to work out what the set
|
|
22
|
+
* is for from the segments alone.
|
|
23
|
+
*/
|
|
24
|
+
label: string;
|
|
25
|
+
/** Drops the caption above the row. The group keeps its accessible name. */
|
|
26
|
+
labelHidden?: boolean;
|
|
27
|
+
/** Submits the value with the surrounding form, through a hidden input. */
|
|
28
|
+
name?: string;
|
|
29
|
+
size?: "sm" | "md";
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
class?: string;
|
|
32
|
+
onchange?: (value: T) => void;
|
|
33
|
+
};
|
|
34
|
+
exports: {};
|
|
35
|
+
bindings: "value";
|
|
36
|
+
slots: {};
|
|
37
|
+
events: {};
|
|
38
|
+
};
|
|
39
|
+
declare class __sveltets_Render<T extends string> {
|
|
40
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
41
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
42
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
43
|
+
bindings(): "value";
|
|
44
|
+
exports(): {};
|
|
45
|
+
}
|
|
46
|
+
interface $$IsomorphicComponent {
|
|
47
|
+
new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
48
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
49
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
50
|
+
<T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
51
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
52
|
+
}
|
|
53
|
+
declare const SegmentedControl: $$IsomorphicComponent;
|
|
54
|
+
type SegmentedControl<T extends string> = InstanceType<typeof SegmentedControl<T>>;
|
|
55
|
+
export default SegmentedControl;
|