@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,4 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Several values from one list, shown as removable chips, with an optional
|
|
4
|
+
* search box in the panel.
|
|
5
|
+
*
|
|
6
|
+
* The open state, the active row, the keyboard model and the dismissal come
|
|
7
|
+
* from internal/listbox, the matching from internal/filter and every class in
|
|
8
|
+
* the panel from internal/panel. A pick leaves the list open, which is the
|
|
9
|
+
* reason the factory reports a selection rather than closing on one.
|
|
10
|
+
*/
|
|
11
|
+
import { applyFilter, type FilterInput } from '../internal/filter.js';
|
|
2
12
|
import {
|
|
3
13
|
FIELD_ERROR,
|
|
4
14
|
FIELD_HINT,
|
|
@@ -7,12 +17,30 @@
|
|
|
7
17
|
controlBorder,
|
|
8
18
|
describedBy,
|
|
9
19
|
} from '../internal/field.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
import { createListbox } from '../internal/listbox.svelte.js';
|
|
21
|
+
import { PANEL_EMPTY, PANEL_LIST, PANEL_SURFACE, panelOption } from '../internal/panel.js';
|
|
22
|
+
import type { ListOption } from './Autocomplete.svelte';
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** The chosen values, in the order they were picked. */
|
|
26
|
+
value?: string[];
|
|
27
|
+
options: ListOption[];
|
|
28
|
+
label?: string;
|
|
29
|
+
hint?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
/** Adds a search field inside the panel. */
|
|
33
|
+
searchable?: boolean;
|
|
15
34
|
disabled?: boolean;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
id?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Replaces the default matcher, or false to switch local filtering off
|
|
39
|
+
* because the list arrived already narrowed, by a server query for instance.
|
|
40
|
+
*/
|
|
41
|
+
filter?: FilterInput<ListOption>;
|
|
42
|
+
class?: string;
|
|
43
|
+
onchange?: (value: string[]) => void;
|
|
16
44
|
}
|
|
17
45
|
|
|
18
46
|
let {
|
|
@@ -26,116 +54,150 @@
|
|
|
26
54
|
disabled = false,
|
|
27
55
|
required = false,
|
|
28
56
|
id = undefined,
|
|
57
|
+
filter = undefined,
|
|
29
58
|
class: cls = '',
|
|
30
59
|
onchange = undefined,
|
|
31
|
-
}:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
id?: string;
|
|
42
|
-
class?: string;
|
|
43
|
-
onchange?: (value: string[]) => void;
|
|
44
|
-
} = $props();
|
|
60
|
+
}: Props = $props();
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* $props.id() and not a random string: a random id differs between the server
|
|
64
|
+
* render and hydration, so every idref built from it names an element the
|
|
65
|
+
* client never rendered. The trigger, the panel and each row are tied
|
|
66
|
+
* together by this one.
|
|
67
|
+
*/
|
|
68
|
+
const uid = $props.id();
|
|
69
|
+
const fieldId = $derived(id ?? uid);
|
|
45
70
|
|
|
46
|
-
let open = $state(false);
|
|
47
71
|
let query = $state('');
|
|
48
|
-
let
|
|
72
|
+
let triggerEl = $state<HTMLDivElement | undefined>();
|
|
73
|
+
let searchEl = $state<HTMLInputElement | undefined>();
|
|
49
74
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
);
|
|
75
|
+
const chosen = $derived(new Set(value));
|
|
76
|
+
const selected = $derived(options.filter((option) => chosen.has(option.value)));
|
|
77
|
+
const rows = $derived(applyFilter(options, query, filter));
|
|
54
78
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
const box = createListbox<ListOption>({
|
|
80
|
+
items: () => rows,
|
|
81
|
+
baseId: () => fieldId,
|
|
82
|
+
onSelect: (option) => pick(option),
|
|
83
|
+
// A search box makes letters query text, so the two typeaheads cannot both
|
|
84
|
+
// own them. Without one, typing jumps to a row the way a select does.
|
|
85
|
+
typeahead: () => !searchable,
|
|
86
|
+
onClose: (reason) => {
|
|
87
|
+
query = '';
|
|
88
|
+
// Tab is the one dismissal that leaves focus inside a panel about to be
|
|
89
|
+
// unmounted: the module hands the key on rather than consuming it, so the
|
|
90
|
+
// browser is about to move focus from wherever it finds it.
|
|
91
|
+
if (reason === 'tab') triggerEl?.focus();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
58
94
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
95
|
+
const anchor = box.anchor;
|
|
96
|
+
const panel = box.panel;
|
|
97
|
+
|
|
98
|
+
function pick(option: ListOption): void {
|
|
99
|
+
if (option.disabled === true) return;
|
|
100
|
+
value = chosen.has(option.value)
|
|
101
|
+
? value.filter((v) => v !== option.value)
|
|
102
|
+
: [...value, option.value];
|
|
62
103
|
onchange?.(value);
|
|
63
104
|
}
|
|
64
105
|
|
|
65
|
-
function remove(v: string) {
|
|
106
|
+
function remove(v: string): void {
|
|
66
107
|
value = value.filter((x) => x !== v);
|
|
67
108
|
onchange?.(value);
|
|
68
109
|
}
|
|
69
110
|
|
|
70
|
-
function
|
|
71
|
-
|
|
111
|
+
function search(event: Event & { currentTarget: HTMLInputElement }): void {
|
|
112
|
+
query = event.currentTarget.value;
|
|
113
|
+
// The keyboard must not rest on a row the new query pushed out from under
|
|
114
|
+
// it: aria-activedescendant would name one option while the ring is drawn
|
|
115
|
+
// on another. Cleared first, so openList lands on the first row that still
|
|
116
|
+
// matches rather than on whatever now sits at the old index.
|
|
117
|
+
box.setActive(-1);
|
|
118
|
+
box.openList();
|
|
72
119
|
}
|
|
73
120
|
|
|
74
|
-
function
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
121
|
+
function onTriggerKeydown(event: KeyboardEvent): void {
|
|
122
|
+
if (disabled) return;
|
|
123
|
+
// A chip's remove button sits inside the trigger, so its keys bubble
|
|
124
|
+
// through here. Taken as the trigger's own they would open the panel and
|
|
125
|
+
// preventDefault the click that removes the chip.
|
|
126
|
+
if (event.target !== event.currentTarget) return;
|
|
127
|
+
if (box.onkeydown(event)) return;
|
|
128
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
129
|
+
|
|
130
|
+
// The trigger owns these two only while the list is closed. Open, Enter
|
|
131
|
+
// belongs to the listbox and Space means the same thing it does: take the
|
|
132
|
+
// row the ring is resting on.
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
if (!box.open) {
|
|
135
|
+
box.openList();
|
|
136
|
+
return;
|
|
78
137
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (e.key === 'Escape') open = false;
|
|
138
|
+
const option = rows[box.activeIndex];
|
|
139
|
+
if (option !== undefined) pick(option);
|
|
82
140
|
}
|
|
83
141
|
|
|
142
|
+
/*
|
|
143
|
+
* Focus moves into the panel deliberately, and it comes back: Escape returns
|
|
144
|
+
* it to the trigger through the shared module, and Tab hands it back above.
|
|
145
|
+
* The attribute this replaces did the first half and none of the second, and
|
|
146
|
+
* the warning it raised was suppressed with an ignore comment rather than
|
|
147
|
+
* answered.
|
|
148
|
+
*/
|
|
84
149
|
$effect(() => {
|
|
85
|
-
if (open)
|
|
86
|
-
document.addEventListener('click', handleOutside, { capture: true });
|
|
87
|
-
document.addEventListener('keydown', handleKey);
|
|
88
|
-
}
|
|
89
|
-
return () => {
|
|
90
|
-
document.removeEventListener('click', handleOutside, { capture: true });
|
|
91
|
-
document.removeEventListener('keydown', handleKey);
|
|
92
|
-
};
|
|
150
|
+
if (box.open && searchEl !== undefined) searchEl.focus();
|
|
93
151
|
});
|
|
94
152
|
</script>
|
|
95
153
|
|
|
96
|
-
<div class="{FIELD_WRAP} {cls}"
|
|
154
|
+
<div class="{FIELD_WRAP} {cls}">
|
|
97
155
|
{#if label}
|
|
98
|
-
<label for={
|
|
99
|
-
{label}{#if required}<span class="
|
|
156
|
+
<label id="{fieldId}-label" for={fieldId} class={FIELD_LABEL}>
|
|
157
|
+
{label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
|
|
100
158
|
</label>
|
|
101
159
|
{/if}
|
|
102
160
|
|
|
103
|
-
<div class="relative">
|
|
104
|
-
<!--
|
|
161
|
+
<div class="relative" use:anchor>
|
|
162
|
+
<!--
|
|
163
|
+
A div and not a button, so the chips' own remove buttons are valid
|
|
164
|
+
children. role="combobox" and not role="button" for the same reason: a
|
|
165
|
+
button's descendants are presentational, which took every remove button
|
|
166
|
+
out of the accessibility tree, and aria-activedescendant says nothing on a
|
|
167
|
+
role that does not support it.
|
|
168
|
+
-->
|
|
105
169
|
<div
|
|
106
|
-
{
|
|
107
|
-
|
|
170
|
+
bind:this={triggerEl}
|
|
171
|
+
id={fieldId}
|
|
172
|
+
role="combobox"
|
|
108
173
|
tabindex={disabled ? -1 : 0}
|
|
109
|
-
aria-
|
|
110
|
-
aria-
|
|
111
|
-
aria-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
open ? (open = false) : open_();
|
|
117
|
-
}
|
|
174
|
+
aria-disabled={disabled ? 'true' : undefined}
|
|
175
|
+
aria-labelledby={label ? `${fieldId}-label` : undefined}
|
|
176
|
+
aria-required={required ? 'true' : undefined}
|
|
177
|
+
aria-describedby={describedBy(fieldId, error, hint)}
|
|
178
|
+
onclick={() => {
|
|
179
|
+
if (!disabled) box.toggle();
|
|
118
180
|
}}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{controlBorder(!!error)}"
|
|
181
|
+
onkeydown={onTriggerKeydown}
|
|
182
|
+
{...box.triggerAttrs}
|
|
183
|
+
class="flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-lg
|
|
184
|
+
border bg-surface-2 px-2.5 py-1.5 text-left text-sm transition-colors duration-150
|
|
185
|
+
outline-none {disabled ? 'cursor-not-allowed opacity-50' : ''} {controlBorder(!!error)}"
|
|
124
186
|
>
|
|
125
187
|
{#if selected.length === 0}
|
|
126
|
-
<span class="
|
|
188
|
+
<span class="px-0.5 text-faint">{placeholder}</span>
|
|
127
189
|
{:else}
|
|
128
|
-
{#each selected as
|
|
190
|
+
{#each selected as option (option.value)}
|
|
129
191
|
<span
|
|
130
|
-
class="inline-flex items-center gap-1 rounded-md bg-brand/10
|
|
192
|
+
class="inline-flex items-center gap-1 rounded-md bg-brand/10 px-2 py-0.5 text-xs text-brand"
|
|
131
193
|
>
|
|
132
|
-
{
|
|
194
|
+
{option.label}
|
|
133
195
|
<button
|
|
134
196
|
type="button"
|
|
135
|
-
aria-label="Remove {
|
|
136
|
-
onclick={(
|
|
137
|
-
|
|
138
|
-
remove(
|
|
197
|
+
aria-label="Remove {option.label}"
|
|
198
|
+
onclick={(event) => {
|
|
199
|
+
event.stopPropagation();
|
|
200
|
+
remove(option.value);
|
|
139
201
|
}}
|
|
140
202
|
class="transition-colors duration-150 hover:text-brand-light"
|
|
141
203
|
>
|
|
@@ -156,7 +218,13 @@
|
|
|
156
218
|
{/each}
|
|
157
219
|
{/if}
|
|
158
220
|
<span class="ml-auto shrink-0 text-faint" aria-hidden="true">
|
|
159
|
-
<svg
|
|
221
|
+
<svg
|
|
222
|
+
width="12"
|
|
223
|
+
height="12"
|
|
224
|
+
viewBox="0 0 12 12"
|
|
225
|
+
fill="none"
|
|
226
|
+
class="transition-transform duration-150 {box.open ? 'rotate-180' : ''}"
|
|
227
|
+
>
|
|
160
228
|
<path
|
|
161
229
|
d="M2 4l4 4 4-4"
|
|
162
230
|
stroke="currentColor"
|
|
@@ -168,42 +236,65 @@
|
|
|
168
236
|
</span>
|
|
169
237
|
</div>
|
|
170
238
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<div
|
|
174
|
-
role="listbox"
|
|
175
|
-
aria-multiselectable="true"
|
|
176
|
-
class="absolute z-50 mt-1 w-full rounded-xl border border-line bg-surface shadow-2xl overflow-hidden"
|
|
177
|
-
>
|
|
239
|
+
{#if box.open}
|
|
240
|
+
<div class="{PANEL_SURFACE} w-full">
|
|
178
241
|
{#if searchable}
|
|
179
|
-
<div class="
|
|
180
|
-
<!-- svelte-ignore a11y_autofocus -->
|
|
242
|
+
<div class="border-b border-line p-2">
|
|
181
243
|
<input
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
244
|
+
bind:this={searchEl}
|
|
245
|
+
type="text"
|
|
246
|
+
role="combobox"
|
|
247
|
+
value={query}
|
|
248
|
+
oninput={search}
|
|
249
|
+
onkeydown={(event) => {
|
|
250
|
+
box.onkeydown(event);
|
|
251
|
+
}}
|
|
252
|
+
placeholder="Search"
|
|
253
|
+
aria-label={label ? `Search ${label}` : 'Search options'}
|
|
254
|
+
{...box.triggerAttrs}
|
|
255
|
+
class="w-full rounded-md border border-line-strong bg-surface-2 px-2.5 py-1.5
|
|
256
|
+
text-sm text-fg outline-none transition-colors duration-150
|
|
257
|
+
placeholder:text-faint focus:border-brand"
|
|
187
258
|
/>
|
|
188
259
|
</div>
|
|
189
260
|
{/if}
|
|
190
|
-
|
|
191
|
-
|
|
261
|
+
|
|
262
|
+
<div class={PANEL_LIST} use:panel {...box.listAttrs} aria-multiselectable="true">
|
|
263
|
+
{#each rows as option, index (option.value)}
|
|
264
|
+
{@const isSelected = chosen.has(option.value)}
|
|
265
|
+
<!--
|
|
266
|
+
role is stated here as well as spread. The compiler checks
|
|
267
|
+
aria-selected against the role it can see in the source and it
|
|
268
|
+
cannot see into a spread, so without this the row reads to it as a
|
|
269
|
+
plain button carrying an attribute a button does not support.
|
|
270
|
+
-->
|
|
192
271
|
<button
|
|
193
272
|
type="button"
|
|
194
273
|
role="option"
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
274
|
+
onclick={() => pick(option)}
|
|
275
|
+
onmouseenter={() => box.setActive(index)}
|
|
276
|
+
onmousedown={(event) => {
|
|
277
|
+
// Pressing a row must not pull focus out of the search box: the
|
|
278
|
+
// list stays open for the next pick, and the caret stays where
|
|
279
|
+
// the user was typing.
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
}}
|
|
282
|
+
aria-selected={isSelected ? 'true' : 'false'}
|
|
283
|
+
{...box.optionAttrs(index)}
|
|
284
|
+
class={panelOption({
|
|
285
|
+
active: box.activeIndex === index,
|
|
286
|
+
selected: isSelected,
|
|
287
|
+
disabled: option.disabled === true,
|
|
288
|
+
})}
|
|
201
289
|
>
|
|
202
290
|
<span
|
|
203
|
-
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border
|
|
204
|
-
|
|
291
|
+
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border
|
|
292
|
+
transition-colors duration-150
|
|
293
|
+
{isSelected
|
|
294
|
+
? 'border-brand bg-brand text-ink'
|
|
295
|
+
: 'border-line-strong bg-surface-2'}"
|
|
205
296
|
>
|
|
206
|
-
{#if isSelected
|
|
297
|
+
{#if isSelected}
|
|
207
298
|
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
208
299
|
<path
|
|
209
300
|
d="M1 4l3 3 5-6"
|
|
@@ -211,15 +302,14 @@
|
|
|
211
302
|
stroke-width="1.5"
|
|
212
303
|
stroke-linecap="round"
|
|
213
304
|
stroke-linejoin="round"
|
|
214
|
-
class="text-ink"
|
|
215
305
|
/>
|
|
216
306
|
</svg>
|
|
217
307
|
{/if}
|
|
218
308
|
</span>
|
|
219
|
-
<span class="text-left">{
|
|
309
|
+
<span class="min-w-0 truncate text-left">{option.label}</span>
|
|
220
310
|
</button>
|
|
221
311
|
{:else}
|
|
222
|
-
<p class=
|
|
312
|
+
<p class={PANEL_EMPTY}>No matches</p>
|
|
223
313
|
{/each}
|
|
224
314
|
</div>
|
|
225
315
|
</div>
|
|
@@ -227,8 +317,8 @@
|
|
|
227
317
|
</div>
|
|
228
318
|
|
|
229
319
|
{#if error}
|
|
230
|
-
<p id={
|
|
320
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
231
321
|
{:else if hint}
|
|
232
|
-
<p id={
|
|
322
|
+
<p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
|
|
233
323
|
{/if}
|
|
234
324
|
</div>
|
|
@@ -1,22 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Several values from one list, shown as removable chips, with an optional
|
|
3
|
+
* search box in the panel.
|
|
4
|
+
*
|
|
5
|
+
* The open state, the active row, the keyboard model and the dismissal come
|
|
6
|
+
* from internal/listbox, the matching from internal/filter and every class in
|
|
7
|
+
* the panel from internal/panel. A pick leaves the list open, which is the
|
|
8
|
+
* reason the factory reports a selection rather than closing on one.
|
|
9
|
+
*/
|
|
10
|
+
import { type FilterInput } from '../internal/filter.js';
|
|
11
|
+
import type { ListOption } from './Autocomplete.svelte';
|
|
12
|
+
interface Props {
|
|
13
|
+
/** The chosen values, in the order they were picked. */
|
|
7
14
|
value?: string[];
|
|
8
|
-
options:
|
|
15
|
+
options: ListOption[];
|
|
9
16
|
label?: string;
|
|
10
17
|
hint?: string;
|
|
11
18
|
error?: string;
|
|
12
19
|
placeholder?: string;
|
|
20
|
+
/** Adds a search field inside the panel. */
|
|
13
21
|
searchable?: boolean;
|
|
14
22
|
disabled?: boolean;
|
|
15
23
|
required?: boolean;
|
|
16
24
|
id?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Replaces the default matcher, or false to switch local filtering off
|
|
27
|
+
* because the list arrived already narrowed, by a server query for instance.
|
|
28
|
+
*/
|
|
29
|
+
filter?: FilterInput<ListOption>;
|
|
17
30
|
class?: string;
|
|
18
31
|
onchange?: (value: string[]) => void;
|
|
19
|
-
}
|
|
20
|
-
declare const MultiSelect: import("svelte").Component
|
|
32
|
+
}
|
|
33
|
+
declare const MultiSelect: import("svelte").Component<Props, {}, "value">;
|
|
21
34
|
type MultiSelect = ReturnType<typeof MultiSelect>;
|
|
22
35
|
export default MultiSelect;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
FIELD_HINT,
|
|
5
5
|
FIELD_LABEL,
|
|
6
6
|
FIELD_WRAP,
|
|
7
|
+
controlBorder,
|
|
7
8
|
describedBy,
|
|
8
9
|
} from '../internal/field.js';
|
|
9
10
|
|
|
@@ -66,8 +67,11 @@
|
|
|
66
67
|
let canDec = $derived(!(disabled || (min !== undefined && value <= min)));
|
|
67
68
|
let canInc = $derived(!(disabled || (max !== undefined && value >= max)));
|
|
68
69
|
|
|
70
|
+
// line-strong, not line: the stepper buttons and the number between them read
|
|
71
|
+
// as one control, and line sits at 1.25:1, so the caps of that control were
|
|
72
|
+
// the part a low-vision user could not find.
|
|
69
73
|
const step_ =
|
|
70
|
-
'flex w-control shrink-0 items-center justify-center border-line bg-surface-2 text-muted ' +
|
|
74
|
+
'flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted ' +
|
|
71
75
|
'transition-colors duration-150 hover:bg-line hover:text-fg ' +
|
|
72
76
|
'disabled:opacity-50 disabled:cursor-not-allowed';
|
|
73
77
|
</script>
|
|
@@ -75,7 +79,7 @@
|
|
|
75
79
|
<div class="{FIELD_WRAP} {cls}">
|
|
76
80
|
{#if label}
|
|
77
81
|
<label for={fieldId} class={FIELD_LABEL}>
|
|
78
|
-
{label}{#if required}<span class="text-danger ml-0.5" aria-
|
|
82
|
+
{label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
|
|
79
83
|
</label>
|
|
80
84
|
{/if}
|
|
81
85
|
|
|
@@ -115,12 +119,12 @@
|
|
|
115
119
|
aria-invalid={error ? 'true' : undefined}
|
|
116
120
|
aria-describedby={describedBy(fieldId, error, hint)}
|
|
117
121
|
class="min-w-0 flex-1 border-y bg-surface-2 text-center text-sm text-fg
|
|
118
|
-
outline-none transition-colors duration-150
|
|
122
|
+
outline-none transition-colors duration-150
|
|
119
123
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
120
124
|
[appearance:textfield]
|
|
121
125
|
[&::-webkit-inner-spin-button]:appearance-none
|
|
122
126
|
[&::-webkit-outer-spin-button]:appearance-none
|
|
123
|
-
{error
|
|
127
|
+
{controlBorder(!!error)}"
|
|
124
128
|
/>
|
|
125
129
|
|
|
126
130
|
<button
|
|
@@ -4,19 +4,52 @@
|
|
|
4
4
|
interface Props {
|
|
5
5
|
title: string;
|
|
6
6
|
description?: string;
|
|
7
|
+
/**
|
|
8
|
+
* The title row for a page with no room for one: the title drops to body
|
|
9
|
+
* size and the description is not rendered. A full-height page, a split
|
|
10
|
+
* pane or a canvas, spends every pixel it does not give away.
|
|
11
|
+
*/
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Drop the bottom margin, for a caller that owns the rhythm itself.
|
|
15
|
+
*
|
|
16
|
+
* The margin is opt-out rather than removed. Forty-three pages across the
|
|
17
|
+
* estate render this component directly and take their heading gap from
|
|
18
|
+
* it, so deleting it outright would have moved every one of them by 32px
|
|
19
|
+
* with nothing in their own source to explain why.
|
|
20
|
+
*/
|
|
21
|
+
flush?: boolean;
|
|
7
22
|
class?: string;
|
|
8
23
|
actions?: Snippet;
|
|
9
24
|
}
|
|
10
25
|
|
|
11
|
-
let {
|
|
26
|
+
let {
|
|
27
|
+
title,
|
|
28
|
+
description = undefined,
|
|
29
|
+
compact = false,
|
|
30
|
+
flush = false,
|
|
31
|
+
class: klass = '',
|
|
32
|
+
actions,
|
|
33
|
+
}: Props = $props();
|
|
12
34
|
</script>
|
|
13
35
|
|
|
14
|
-
|
|
36
|
+
<!-- The margin is stated before the consumer's class so a page can still
|
|
37
|
+
override it, which is the whole reason it is a separate token rather than
|
|
38
|
+
part of the layout: it used to be appended ahead of `klass` unconditionally,
|
|
39
|
+
and a page asking for a different gap shipped two competing margin
|
|
40
|
+
utilities in one attribute with Tailwind's emitted order picking the winner
|
|
41
|
+
rather than the page. -->
|
|
42
|
+
<header class="flex flex-wrap items-start justify-between gap-4 {flush ? '' : 'mb-8'} {klass}">
|
|
15
43
|
<div class="min-w-0">
|
|
16
44
|
<!-- The app shells render their own h1 for the current route, so a heading
|
|
17
45
|
lookup by name matches two elements. This names the page's own title. -->
|
|
18
|
-
<h1
|
|
19
|
-
|
|
46
|
+
<h1
|
|
47
|
+
data-testid="page-title"
|
|
48
|
+
class={compact ? 'text-sm font-semibold text-fg' : 'text-2xl font-bold text-fg'}
|
|
49
|
+
>
|
|
50
|
+
{title}
|
|
51
|
+
</h1>
|
|
52
|
+
{#if description && !compact}<p class="mt-1 text-sm text-muted">{description}</p>{/if}
|
|
20
53
|
</div>
|
|
21
54
|
{#if actions}
|
|
22
55
|
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
@@ -2,6 +2,21 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
title: string;
|
|
4
4
|
description?: string;
|
|
5
|
+
/**
|
|
6
|
+
* The title row for a page with no room for one: the title drops to body
|
|
7
|
+
* size and the description is not rendered. A full-height page, a split
|
|
8
|
+
* pane or a canvas, spends every pixel it does not give away.
|
|
9
|
+
*/
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Drop the bottom margin, for a caller that owns the rhythm itself.
|
|
13
|
+
*
|
|
14
|
+
* The margin is opt-out rather than removed. Forty-three pages across the
|
|
15
|
+
* estate render this component directly and take their heading gap from
|
|
16
|
+
* it, so deleting it outright would have moved every one of them by 32px
|
|
17
|
+
* with nothing in their own source to explain why.
|
|
18
|
+
*/
|
|
19
|
+
flush?: boolean;
|
|
5
20
|
class?: string;
|
|
6
21
|
actions?: Snippet;
|
|
7
22
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The page frame: the gutter, the content cap, the centring and the section
|
|
4
|
+
* rhythm, all owned by the shell so a page cannot pick its own.
|
|
5
|
+
*
|
|
6
|
+
* Nothing in the library owned the frame, so every page built one. Across one
|
|
7
|
+
* app 31 of 35 pages restate the gutter in four spellings, five content caps
|
|
8
|
+
* are in use with no rule for choosing between them, and one page renders
|
|
9
|
+
* against the left edge of the window because it set a cap and forgot
|
|
10
|
+
* `mx-auto`. Centring and `w-full` come with the gutter here, in PAGE_PAD, so
|
|
11
|
+
* there is no order of props that leaves them out.
|
|
12
|
+
*
|
|
13
|
+
* The shell renders no landmark of its own. An app shell already owns `main`,
|
|
14
|
+
* `nav` and the rest, and a second `main` changes what a landmark query
|
|
15
|
+
* matches for every assistive technology reading the page.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
import { PAGE_PAD, PAGE_STACK, PAGE_WIDTH, type PageWidth } from '../internal/layout.js';
|
|
19
|
+
import PageHeader from './PageHeader.svelte';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** The page's only h1, rendered through PageHeader. */
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Content cap. narrow, default, wide, full. */
|
|
26
|
+
width?: PageWidth;
|
|
27
|
+
/**
|
|
28
|
+
* A full-height page that manages its own scrolling, for instance a split
|
|
29
|
+
* pane or a canvas. No gutter, no cap, and the title renders compact.
|
|
30
|
+
*/
|
|
31
|
+
fill?: boolean;
|
|
32
|
+
/** Rendered above the title at one fixed distance. */
|
|
33
|
+
breadcrumb?: Snippet;
|
|
34
|
+
/** Right-aligned controls in the title row. */
|
|
35
|
+
actions?: Snippet;
|
|
36
|
+
class?: string;
|
|
37
|
+
children: Snippet;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
title,
|
|
42
|
+
description = undefined,
|
|
43
|
+
width = 'default',
|
|
44
|
+
fill = false,
|
|
45
|
+
breadcrumb,
|
|
46
|
+
actions,
|
|
47
|
+
class: klass = '',
|
|
48
|
+
children,
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A fill page owns the viewport instead of sitting in it: no gutter, no cap,
|
|
53
|
+
* and the height the title row leaves goes to the content, so a split pane
|
|
54
|
+
* scrolls inside the page rather than scrolling the page. Two pages carry a
|
|
55
|
+
* documented waiver against their app's own layout lint for exactly this
|
|
56
|
+
* shape, which is the argument for the shell supporting it.
|
|
57
|
+
*
|
|
58
|
+
* `min-h-0` is load bearing on the column: a flex item refuses to shrink
|
|
59
|
+
* below its content by default, so without it the pane runs past the bottom
|
|
60
|
+
* of the window and takes its own scrollbar out of reach.
|
|
61
|
+
*/
|
|
62
|
+
const frame = $derived(
|
|
63
|
+
fill
|
|
64
|
+
? 'flex h-full min-h-0 w-full flex-col gap-4'
|
|
65
|
+
: `${PAGE_PAD} ${PAGE_WIDTH[width]} ${PAGE_STACK}`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** The content stack. On a fill page it also takes the leftover height. */
|
|
69
|
+
const content = $derived(fill ? `${PAGE_STACK} min-h-0 flex-1` : PAGE_STACK);
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div class="{frame} {klass}">
|
|
73
|
+
<!-- The breadcrumb and the title are one group, so the distance between them
|
|
74
|
+
is fixed here and does not change with whether a description is set. -->
|
|
75
|
+
<div class="flex flex-col gap-2">
|
|
76
|
+
{#if breadcrumb}{@render breadcrumb()}{/if}
|
|
77
|
+
<!-- flush: the shell's own section stack supplies the gap below the title,
|
|
78
|
+
so the header must not add a second one. -->
|
|
79
|
+
<PageHeader {title} {description} {actions} compact={fill} flush />
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class={content}>
|
|
83
|
+
{@render children()}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|