@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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. 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
- // MultiSelect: pick several options, shown as removable chips, with an optional
11
- // search box. Binds to a string[] of selected values.
12
- interface Option {
13
- value: string;
14
- label: string;
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
- value?: string[];
33
- options: Option[];
34
- label?: string;
35
- hint?: string;
36
- error?: string;
37
- placeholder?: string;
38
- searchable?: boolean;
39
- disabled?: boolean;
40
- required?: boolean;
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 containerEl: HTMLDivElement | undefined = $state();
72
+ let triggerEl = $state<HTMLDivElement | undefined>();
73
+ let searchEl = $state<HTMLInputElement | undefined>();
49
74
 
50
- const selected = $derived(options.filter((o) => value.includes(o.value)));
51
- const filtered = $derived(
52
- options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase())),
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
- function isSelected(v: string) {
56
- return value.includes(v);
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
- function toggle(opt: Option) {
60
- if (opt.disabled) return;
61
- value = isSelected(opt.value) ? value.filter((v) => v !== opt.value) : [...value, opt.value];
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 open_() {
71
- if (!disabled) open = true;
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 handleOutside(e: MouseEvent) {
75
- if (containerEl && !containerEl.contains(e.target as Node)) {
76
- open = false;
77
- query = '';
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
- function handleKey(e: KeyboardEvent) {
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}" bind:this={containerEl}>
154
+ <div class="{FIELD_WRAP} {cls}">
97
155
  {#if label}
98
- <label for={id} class={FIELD_LABEL}>
99
- {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
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
- <!-- Trigger (a div, not a button, so the chip remove-buttons are valid children) -->
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
- {id}
107
- role="button"
170
+ bind:this={triggerEl}
171
+ id={fieldId}
172
+ role="combobox"
108
173
  tabindex={disabled ? -1 : 0}
109
- aria-haspopup="listbox"
110
- aria-expanded={open}
111
- aria-disabled={disabled}
112
- onclick={() => (open ? (open = false) : open_())}
113
- onkeydown={(e) => {
114
- if (e.key === 'Enter' || e.key === ' ') {
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
- aria-describedby={describedBy(id, error, hint)}
120
- class="w-full min-h-control flex flex-wrap items-center gap-1.5 rounded-lg border bg-surface-2
121
- px-2.5 py-1.5 text-left text-sm transition-colors duration-150 outline-none cursor-pointer
122
- {disabled ? 'opacity-50 cursor-not-allowed' : ''}
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="text-faint px-0.5">{placeholder}</span>
188
+ <span class="px-0.5 text-faint">{placeholder}</span>
127
189
  {:else}
128
- {#each selected as opt (opt.value)}
190
+ {#each selected as option (option.value)}
129
191
  <span
130
- class="inline-flex items-center gap-1 rounded-md bg-brand/10 text-brand px-2 py-0.5 text-xs"
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
- {opt.label}
194
+ {option.label}
133
195
  <button
134
196
  type="button"
135
- aria-label="Remove {opt.label}"
136
- onclick={(e) => {
137
- e.stopPropagation();
138
- remove(opt.value);
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 width="12" height="12" viewBox="0 0 12 12" fill="none">
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
- <!-- Panel -->
172
- {#if open}
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="p-2 border-b border-line">
180
- <!-- svelte-ignore a11y_autofocus -->
242
+ <div class="border-b border-line p-2">
181
243
  <input
182
- autofocus
183
- bind:value={query}
184
- placeholder="Search…"
185
- class="w-full rounded-md bg-surface-2 border border-line px-2.5 py-1.5 text-sm text-fg
186
- placeholder:text-faint outline-none focus:border-brand"
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
- <div class="max-h-60 overflow-y-auto py-1">
191
- {#each filtered as opt (opt.value)}
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
- aria-selected={isSelected(opt.value)}
196
- disabled={opt.disabled}
197
- onclick={() => toggle(opt)}
198
- class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-fg transition-colors duration-150
199
- hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed
200
- outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/50"
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 transition-colors duration-150
204
- {isSelected(opt.value) ? 'bg-brand border-brand' : 'bg-surface-2 border-line'}"
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(opt.value)}
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">{opt.label}</span>
309
+ <span class="min-w-0 truncate text-left">{option.label}</span>
220
310
  </button>
221
311
  {:else}
222
- <p class="px-3 py-2 text-sm text-faint">No matches</p>
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={id ? `${id}-error` : undefined} class={FIELD_ERROR}>{error}</p>
320
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
231
321
  {:else if hint}
232
- <p id={id ? `${id}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
322
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
233
323
  {/if}
234
324
  </div>
@@ -1,22 +1,35 @@
1
- interface Option {
2
- value: string;
3
- label: string;
4
- disabled?: boolean;
5
- }
6
- type $$ComponentProps = {
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: Option[];
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<$$ComponentProps, {}, "value">;
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-label="required">*</span>{/if}
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 focus:border-brand
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 ? 'border-danger' : 'border-line'}"
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 { title, description = undefined, class: klass = '', actions }: Props = $props();
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
- <header class="mb-8 flex flex-wrap items-start justify-between gap-4 {klass}">
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 data-testid="page-title" class="text-2xl font-bold text-fg">{title}</h1>
19
- {#if description}<p class="mt-1 text-sm text-muted">{description}</p>{/if}
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>