@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
package/README.md CHANGED
@@ -188,7 +188,7 @@ src/
188
188
  breaking changes bump the **minor** version; additive changes bump the **patch**.
189
189
  Every release is logged in [`CHANGELOG.md`](CHANGELOG.md) and on the docs site.
190
190
 
191
- Maintainers: see [`PUBLISHING.md`](PUBLISHING.md) for the release workflow.
191
+ Maintainers: the release workflow is [`CONTRIBUTING.md`](CONTRIBUTING.md#releases).
192
192
 
193
193
  ## Contributing
194
194
 
@@ -35,7 +35,7 @@
35
35
  aria-controls={panelId}
36
36
  class="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium
37
37
  text-fg transition-colors duration-150 hover:bg-surface-2/50
38
- outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/50
38
+ outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
39
39
  {open ? 'bg-surface-2/40' : ''}"
40
40
  >
41
41
  <span>{title}</span>
@@ -1,4 +1,36 @@
1
+ <script lang="ts" module>
2
+ import type { ListboxItem } from '../internal/listbox.svelte.js';
3
+
4
+ /**
5
+ * One row of a filterable option list.
6
+ *
7
+ * Declared here and imported by MultiSelect, which carried the same interface
8
+ * written out character for character. Two declarations are two chances to
9
+ * answer the same question differently, and these two had already drifted
10
+ * apart in what they let a consumer say: neither could describe a row by
11
+ * anything but its label, so an option a user knows by a code or a synonym
12
+ * could not be found by typing it.
13
+ */
14
+ export interface ListOption extends ListboxItem {
15
+ /**
16
+ * Extra words the default matcher searches alongside the label. Absent on
17
+ * every option that ships today, so adopting it narrows no existing list.
18
+ */
19
+ keywords?: readonly string[];
20
+ }
21
+ </script>
22
+
1
23
  <script lang="ts">
24
+ /**
25
+ * A combobox: type to narrow the list, arrow through what is left, Enter to
26
+ * choose.
27
+ *
28
+ * The open state, the active row, the keyboard model and the dismissal come
29
+ * from internal/listbox, the matching from internal/filter and every class in
30
+ * the panel from internal/panel. What stays here is the one thing a combobox
31
+ * owns: whether the input is showing a query being typed or the label of the
32
+ * row already chosen.
33
+ */
2
34
  import {
3
35
  CONTROL_BASE,
4
36
  FIELD_ERROR,
@@ -8,12 +40,31 @@
8
40
  controlBorder,
9
41
  describedBy,
10
42
  } from '../internal/field.js';
11
- // Autocomplete (combobox): type to filter a list, navigate with ↑/↓, select with
12
- // Enter. Binds `value` to the chosen option's value; shows its label in the input.
13
- interface Option {
14
- value: string;
15
- label: string;
43
+ import { applyFilter, type FilterInput } from '../internal/filter.js';
44
+ import { createListbox } from '../internal/listbox.svelte.js';
45
+ import { PANEL_EMPTY, PANEL_LIST, PANEL_SURFACE, panelOption } from '../internal/panel.js';
46
+
47
+ interface Props {
48
+ /** The chosen option's value. Empty for none. */
49
+ value?: string;
50
+ options: ListOption[];
51
+ label?: string;
52
+ hint?: string;
53
+ error?: string;
54
+ placeholder?: string;
16
55
  disabled?: boolean;
56
+ required?: boolean;
57
+ id?: string;
58
+ name?: string;
59
+ /** Offers a button that empties the field. */
60
+ allowClear?: boolean;
61
+ /**
62
+ * Replaces the default matcher, or false to switch local filtering off
63
+ * because the list arrived already narrowed, by a server query for instance.
64
+ */
65
+ filter?: FilterInput<ListOption>;
66
+ class?: string;
67
+ onchange?: (value: string) => void;
17
68
  }
18
69
 
19
70
  let {
@@ -28,126 +79,123 @@
28
79
  id = undefined,
29
80
  name = undefined,
30
81
  allowClear = true,
82
+ filter = undefined,
31
83
  class: cls = '',
32
84
  onchange = undefined,
33
- }: {
34
- value?: string;
35
- options: Option[];
36
- label?: string;
37
- hint?: string;
38
- error?: string;
39
- placeholder?: string;
40
- disabled?: boolean;
41
- required?: boolean;
42
- id?: string;
43
- name?: string;
44
- allowClear?: boolean;
45
- class?: string;
46
- onchange?: (value: string) => void;
47
- } = $props();
85
+ }: Props = $props();
48
86
 
49
- const uid = `ac-${Math.random().toString(36).slice(2, 9)}`;
50
- const listId = $derived(id ? `${id}-list` : uid);
87
+ /*
88
+ * $props.id() and not a random string: a random id differs between the server
89
+ * render and hydration, so the aria-controls this field emitted named an
90
+ * element the client had never rendered and the relationship was dropped in
91
+ * silence. Every idref in the panel is built from this one.
92
+ */
93
+ const uid = $props.id();
94
+ const fieldId = $derived(id ?? uid);
51
95
 
52
- let open = $state(false);
53
96
  let query = $state('');
54
- let active = $state(0); // highlighted index
55
- let containerEl: HTMLDivElement | undefined = $state();
56
- let dirty = $state(false); // user is editing the query vs. showing the selected label
97
+ /** Whether the input holds a query being typed or the chosen row's label. */
98
+ let dirty = $state(false);
99
+ let inputEl = $state<HTMLInputElement | undefined>();
100
+ let fieldEl = $state<HTMLDivElement | undefined>();
57
101
 
58
- const selectedLabel = $derived(options.find((o) => o.value === value)?.label ?? '');
102
+ const selectedLabel = $derived(options.find((option) => option.value === value)?.label ?? '');
59
103
  const display = $derived(dirty ? query : selectedLabel);
60
- const filtered = $derived(
61
- dirty && query.trim()
62
- ? options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()))
63
- : options,
64
- );
65
-
66
- function choose(opt: Option) {
67
- if (opt.disabled) return;
68
- value = opt.value;
69
- onchange?.(opt.value);
70
- query = '';
71
- dirty = false;
72
- open = false;
104
+ // Not narrowed until the user types: opening the field shows the whole list,
105
+ // with the row already chosen under the ring.
106
+ const rows = $derived(applyFilter(options, dirty ? query : '', filter));
107
+
108
+ // No typeahead: every letter here is query text, and a second matcher jumping
109
+ // the active row would fight the filter for the same keys.
110
+ const box = createListbox<ListOption>({
111
+ items: () => rows,
112
+ baseId: () => fieldId,
113
+ onSelect: (option) => choose(option),
114
+ onClose: () => {
115
+ // The input goes back to showing what is chosen. A dismissed query left
116
+ // on screen reads as a value the field does not hold.
117
+ query = '';
118
+ dirty = false;
119
+ },
120
+ });
121
+
122
+ const anchor = box.anchor;
123
+ const panel = box.panel;
124
+
125
+ function choose(option: ListOption): void {
126
+ if (option.disabled === true) return;
127
+ value = option.value;
128
+ onchange?.(option.value);
129
+ // The factory leaves the list open, because a multi-value control collects
130
+ // several picks in one pass. This one takes a single value.
131
+ box.close('select');
73
132
  }
74
133
 
75
- function clear() {
134
+ function clear(): void {
76
135
  value = '';
77
136
  query = '';
78
137
  dirty = false;
79
138
  onchange?.('');
139
+ // The button is about to unmount with focus on it, which would drop focus
140
+ // to the body and leave a keyboard user at the top of the page.
141
+ inputEl?.focus();
80
142
  }
81
143
 
82
- function onInput(e: Event & { currentTarget: HTMLInputElement }) {
83
- query = e.currentTarget.value;
144
+ function search(event: Event & { currentTarget: HTMLInputElement }): void {
145
+ query = event.currentTarget.value;
84
146
  dirty = true;
85
- open = true;
86
- active = 0;
147
+ // The keyboard must not rest on a row the new query pushed out from under
148
+ // it: aria-activedescendant would name one option while the ring is drawn
149
+ // on another. Cleared first, so openList lands on the first row that still
150
+ // matches rather than on whatever now sits at the old index.
151
+ box.setActive(-1);
152
+ box.openList();
87
153
  }
88
154
 
89
- function onKeydown(e: KeyboardEvent) {
90
- if (e.key === 'ArrowDown') {
91
- e.preventDefault();
92
- open = true;
93
- active = Math.min(active + 1, filtered.length - 1);
94
- } else if (e.key === 'ArrowUp') {
95
- e.preventDefault();
96
- active = Math.max(active - 1, 0);
97
- } else if (e.key === 'Enter') {
98
- if (open && filtered[active]) {
99
- e.preventDefault();
100
- choose(filtered[active]);
101
- }
102
- } else if (e.key === 'Escape') {
103
- open = false;
104
- dirty = false;
105
- query = '';
106
- }
107
- }
155
+ function onFocus(event: FocusEvent): void {
156
+ // Focus arriving from inside the field is focus coming back, not the user
157
+ // entering it. Escape hands focus from a row to the input, and reopening on
158
+ // that would undo the dismissal the user had just asked for.
159
+ const from = event.relatedTarget;
160
+ if (from instanceof Node && fieldEl?.contains(from) === true) return;
108
161
 
109
- function handleOutside(e: MouseEvent) {
110
- if (containerEl && !containerEl.contains(e.target as Node)) {
111
- open = false;
112
- dirty = false;
113
- query = '';
114
- }
162
+ // Opening on the row the field already holds, so the first ArrowDown moves
163
+ // from the current value rather than from the top of the list.
164
+ const index = rows.findIndex((option) => option.value === value);
165
+ box.openList(index >= 0 ? index : undefined);
115
166
  }
116
-
117
- $effect(() => {
118
- if (open) document.addEventListener('click', handleOutside, { capture: true });
119
- return () => document.removeEventListener('click', handleOutside, { capture: true });
120
- });
121
167
  </script>
122
168
 
123
- <div class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
169
+ <div class="{FIELD_WRAP} {cls}">
124
170
  {#if label}
125
- <label for={id} class={FIELD_LABEL}>
126
- {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
171
+ <label for={fieldId} class={FIELD_LABEL}>
172
+ {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
127
173
  </label>
128
174
  {/if}
129
175
 
130
- <div class="relative">
176
+ <div class="relative" bind:this={fieldEl} use:anchor>
131
177
  <input
132
- {id}
178
+ bind:this={inputEl}
179
+ id={fieldId}
133
180
  {name}
134
181
  {placeholder}
135
182
  {disabled}
136
183
  {required}
137
184
  role="combobox"
138
- aria-expanded={open}
139
- aria-controls={listId}
140
185
  aria-autocomplete="list"
141
186
  autocomplete="off"
142
187
  value={display}
143
- oninput={onInput}
144
- onkeydown={onKeydown}
145
- onfocus={() => (open = true)}
146
- onblur={() => setTimeout(() => (open = false), 150)}
188
+ oninput={search}
189
+ onfocus={onFocus}
190
+ onkeydown={(event) => {
191
+ box.onkeydown(event);
192
+ }}
147
193
  aria-invalid={error ? 'true' : undefined}
148
- aria-describedby={describedBy(id, error, hint)}
194
+ aria-describedby={describedBy(fieldId, error, hint)}
195
+ {...box.triggerAttrs}
149
196
  class="{CONTROL_BASE} {controlBorder(!!error)} {allowClear && value ? 'pr-8' : 'pr-3'}"
150
197
  />
198
+
151
199
  {#if allowClear && value && !disabled}
152
200
  <button
153
201
  type="button"
@@ -170,49 +218,67 @@
170
218
  </button>
171
219
  {/if}
172
220
 
173
- {#if open}
174
- <div
175
- id={listId}
176
- role="listbox"
177
- class="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-xl border border-line
178
- bg-surface shadow-2xl py-1"
179
- >
180
- {#each filtered as opt, i (opt.value)}
181
- <button
182
- type="button"
183
- role="option"
184
- aria-selected={opt.value === value}
185
- disabled={opt.disabled}
186
- onmouseenter={() => (active = i)}
187
- onclick={() => choose(opt)}
188
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors duration-150
189
- disabled:opacity-40 disabled:cursor-not-allowed
190
- {i === active ? 'bg-surface-2' : ''}
191
- {opt.value === value ? 'text-brand' : 'text-fg'}"
192
- >
193
- <span>{opt.label}</span>
194
- {#if opt.value === value}
195
- <svg width="12" height="10" viewBox="0 0 12 10" fill="none" aria-hidden="true">
196
- <path
197
- d="M1 5l3.5 3.5L11 1.5"
198
- stroke="currentColor"
199
- stroke-width="1.5"
200
- stroke-linecap="round"
201
- stroke-linejoin="round"
202
- />
203
- </svg>
204
- {/if}
205
- </button>
206
- {:else}
207
- <p class="px-3 py-2 text-sm text-faint">No matches</p>
208
- {/each}
221
+ {#if box.open}
222
+ <div class="{PANEL_SURFACE} w-full">
223
+ <div class={PANEL_LIST} use:panel {...box.listAttrs}>
224
+ {#each rows as option, index (option.value)}
225
+ {@const isSelected = option.value === value}
226
+ <!--
227
+ role is stated here as well as spread. The compiler checks
228
+ aria-selected against the role it can see in the source and it
229
+ cannot see into a spread, so without this the row reads to it as a
230
+ plain button carrying an attribute a button does not support.
231
+ -->
232
+ <button
233
+ type="button"
234
+ role="option"
235
+ onclick={() => choose(option)}
236
+ onmouseenter={() => box.setActive(index)}
237
+ onmousedown={(event) => {
238
+ // Pressing a row must not pull focus out of the input: the
239
+ // caret stays where the user is typing, and the field never
240
+ // has to be given focus back after a pick.
241
+ event.preventDefault();
242
+ }}
243
+ aria-selected={isSelected ? 'true' : 'false'}
244
+ {...box.optionAttrs(index)}
245
+ class={panelOption({
246
+ active: box.activeIndex === index,
247
+ selected: isSelected,
248
+ disabled: option.disabled === true,
249
+ })}
250
+ >
251
+ <span class="min-w-0 truncate">{option.label}</span>
252
+ {#if isSelected}
253
+ <svg
254
+ width="12"
255
+ height="10"
256
+ viewBox="0 0 12 10"
257
+ fill="none"
258
+ class="ml-auto shrink-0"
259
+ aria-hidden="true"
260
+ >
261
+ <path
262
+ d="M1 5l3.5 3.5L11 1.5"
263
+ stroke="currentColor"
264
+ stroke-width="1.5"
265
+ stroke-linecap="round"
266
+ stroke-linejoin="round"
267
+ />
268
+ </svg>
269
+ {/if}
270
+ </button>
271
+ {:else}
272
+ <p class={PANEL_EMPTY}>No matches</p>
273
+ {/each}
274
+ </div>
209
275
  </div>
210
276
  {/if}
211
277
  </div>
212
278
 
213
279
  {#if error}
214
- <p id={id ? `${id}-error` : undefined} class={FIELD_ERROR}>{error}</p>
280
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
215
281
  {:else if hint}
216
- <p id={id ? `${id}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
282
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
217
283
  {/if}
218
284
  </div>
@@ -1,11 +1,26 @@
1
- interface Option {
2
- value: string;
3
- label: string;
4
- disabled?: boolean;
1
+ import type { ListboxItem } from '../internal/listbox.svelte.js';
2
+ /**
3
+ * One row of a filterable option list.
4
+ *
5
+ * Declared here and imported by MultiSelect, which carried the same interface
6
+ * written out character for character. Two declarations are two chances to
7
+ * answer the same question differently, and these two had already drifted
8
+ * apart in what they let a consumer say: neither could describe a row by
9
+ * anything but its label, so an option a user knows by a code or a synonym
10
+ * could not be found by typing it.
11
+ */
12
+ export interface ListOption extends ListboxItem {
13
+ /**
14
+ * Extra words the default matcher searches alongside the label. Absent on
15
+ * every option that ships today, so adopting it narrows no existing list.
16
+ */
17
+ keywords?: readonly string[];
5
18
  }
6
- type $$ComponentProps = {
19
+ import { type FilterInput } from '../internal/filter.js';
20
+ interface Props {
21
+ /** The chosen option's value. Empty for none. */
7
22
  value?: string;
8
- options: Option[];
23
+ options: ListOption[];
9
24
  label?: string;
10
25
  hint?: string;
11
26
  error?: string;
@@ -14,10 +29,16 @@ type $$ComponentProps = {
14
29
  required?: boolean;
15
30
  id?: string;
16
31
  name?: string;
32
+ /** Offers a button that empties the field. */
17
33
  allowClear?: boolean;
34
+ /**
35
+ * Replaces the default matcher, or false to switch local filtering off
36
+ * because the list arrived already narrowed, by a server query for instance.
37
+ */
38
+ filter?: FilterInput<ListOption>;
18
39
  class?: string;
19
40
  onchange?: (value: string) => void;
20
- };
21
- declare const Autocomplete: import("svelte").Component<$$ComponentProps, {}, "value">;
41
+ }
42
+ declare const Autocomplete: import("svelte").Component<Props, {}, "value">;
22
43
  type Autocomplete = ReturnType<typeof Autocomplete>;
23
44
  export default Autocomplete;
@@ -47,10 +47,10 @@
47
47
  primary: 'bg-brand text-ink hover:bg-brand-light active:bg-brand shadow-sm shadow-brand/20',
48
48
  violet:
49
49
  'bg-violet text-ink hover:brightness-110 active:brightness-100 shadow-sm shadow-violet/20',
50
- secondary: 'bg-surface-2 text-fg border border-line hover:bg-line',
50
+ secondary: 'bg-surface-2 text-fg border border-line-strong hover:bg-line',
51
51
  danger: 'bg-danger text-ink hover:brightness-110 active:brightness-100',
52
52
  ghost: 'text-muted hover:bg-surface-2 hover:text-fg',
53
- outline: 'border border-line text-fg hover:border-brand hover:text-brand',
53
+ outline: 'border border-line-strong text-fg hover:border-brand hover:text-brand',
54
54
  };
55
55
 
56
56
  const sizes: Record<Size, string> = {
@@ -68,15 +68,37 @@
68
68
 
69
69
  // Reject javascript: and data: URIs - only allow standard schemes and relative URLs.
70
70
  let safeHref = $derived(href && !/^(javascript|data):/i.test(href) ? href : undefined);
71
+
72
+ /**
73
+ * An anchor has no `disabled`. The prop was accepted and then dropped on this
74
+ * branch, so `<Button href="..." disabled>` rendered a link that looked
75
+ * ordinary and navigated on click, and `disabled:opacity-50` never matched
76
+ * because that pseudo-class does not apply to `a`. Dropping the href is what
77
+ * actually takes it out of the tab order and stops activation.
78
+ */
79
+ const inert = $derived(disabled || loading);
71
80
  </script>
72
81
 
73
82
  {#if href}
74
- <a href={safeHref} class={cls} {...rest}>
83
+ <a
84
+ href={inert ? undefined : safeHref}
85
+ class="{cls} {inert ? 'pointer-events-none opacity-50' : ''}"
86
+ aria-disabled={inert ? 'true' : undefined}
87
+ tabindex={inert ? -1 : undefined}
88
+ {...rest}
89
+ >
75
90
  {#if loading}<Spinner size={spinnerSize} />{/if}
76
91
  {@render children()}
77
92
  </a>
78
93
  {:else}
79
- <button {type} disabled={disabled || loading} {onclick} class={cls} {...rest}>
94
+ <button
95
+ {type}
96
+ disabled={disabled || loading}
97
+ aria-busy={loading ? 'true' : undefined}
98
+ {onclick}
99
+ class={cls}
100
+ {...rest}
101
+ >
80
102
  {#if loading}<Spinner size={spinnerSize} />{/if}
81
103
  {@render children()}
82
104
  </button>
@@ -1,11 +1,31 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
2
+ import type { Component, Snippet } from 'svelte';
3
+ import { sectionHeading } from '../internal/layout.js';
3
4
 
4
5
  type Pad = 'none' | 'sm' | 'md' | 'lg';
5
6
 
6
7
  interface Props {
7
8
  title?: string;
8
9
  description?: string;
10
+ /**
11
+ * The card's own section heading.
12
+ *
13
+ * The header band has three ways in, and they resolve in this order:
14
+ * `header` wins over everything, then `heading`, then `title`. A caller who
15
+ * hands a snippet has already decided what the band contains, so the
16
+ * component adds nothing to it. `heading` renders the icon, a real heading
17
+ * element at the level the caller names, and the meta row that seven
18
+ * hand-rolled section shells wrote out for themselves. `title` predates it
19
+ * and still renders exactly what it always did, so the cards already
20
+ * shipping do not move. Set one of the three.
21
+ */
22
+ heading?: string;
23
+ /** 2 under a page title, 3 inside another card. */
24
+ headingLevel?: 2 | 3;
25
+ /** Drawn before the heading. Decorative, so it carries no name of its own. */
26
+ icon?: Component<{ size?: number; class?: string }>;
27
+ /** A count, a status or a timestamp, at the end of the heading row. */
28
+ meta?: string;
9
29
  pad?: Pad;
10
30
  hover?: boolean;
11
31
  class?: string;
@@ -19,6 +39,10 @@
19
39
  let {
20
40
  title = undefined,
21
41
  description = undefined,
42
+ heading = undefined,
43
+ headingLevel = 3,
44
+ icon = undefined,
45
+ meta = undefined,
22
46
  pad = 'md',
23
47
  hover = false,
24
48
  class: klass = '',
@@ -29,6 +53,23 @@
29
53
  children,
30
54
  }: Props = $props();
31
55
 
56
+ /**
57
+ * A clickable Card advertises itself as a button and used to do nothing when
58
+ * you pressed one. It took `role="button"` and `tabindex="0"` from `onclick`
59
+ * alone, so it entered the tab order and then ignored Enter and Space unless
60
+ * the page happened to pass its own `onkeydown`. Native button semantics are
61
+ * the contract here, so the component honours them itself and still forwards
62
+ * whatever the consumer supplied.
63
+ */
64
+ function activate(e: KeyboardEvent) {
65
+ onkeydown?.(e);
66
+ if (!onclick || e.defaultPrevented) return;
67
+ if (e.key !== 'Enter' && e.key !== ' ') return;
68
+ // Space scrolls the page on a non-button element.
69
+ e.preventDefault();
70
+ (e.currentTarget as HTMLElement).click();
71
+ }
72
+
32
73
  const pads: Record<Pad, string> = {
33
74
  none: '',
34
75
  sm: 'p-4',
@@ -47,12 +88,29 @@
47
88
  role={onclick ? 'button' : undefined}
48
89
  tabindex={onclick ? 0 : undefined}
49
90
  {onclick}
50
- {onkeydown}
91
+ onkeydown={onclick ? activate : onkeydown}
51
92
  >
52
- {#if header || title}
93
+ {#if header || heading || title}
53
94
  <div class="px-5 py-4 border-b border-line">
54
95
  {#if header}
55
96
  {@render header()}
97
+ {:else if heading}
98
+ <div class="flex items-center gap-2">
99
+ {#if icon}
100
+ {@const Icon = icon}
101
+ <Icon size={16} class="shrink-0 text-faint" />
102
+ {/if}
103
+ <!-- Two branches rather than one dynamic element: the level is a
104
+ document structure decision, and a reader of this file should be
105
+ able to see both headings the card can produce. -->
106
+ {#if headingLevel === 2}
107
+ <h2 class={sectionHeading(2)}>{heading}</h2>
108
+ {:else}
109
+ <h3 class={sectionHeading(3)}>{heading}</h3>
110
+ {/if}
111
+ {#if meta}<span class="ml-auto shrink-0 text-xs text-faint">{meta}</span>{/if}
112
+ </div>
113
+ {#if description}<p class="text-sm text-muted mt-0.5">{description}</p>{/if}
56
114
  {:else}
57
115
  <h3 class="font-semibold text-fg">{title}</h3>
58
116
  {#if description}<p class="text-sm text-muted mt-0.5">{description}</p>{/if}
@@ -1,8 +1,30 @@
1
- import type { Snippet } from 'svelte';
1
+ import type { Component, Snippet } from 'svelte';
2
2
  type Pad = 'none' | 'sm' | 'md' | 'lg';
3
3
  interface Props {
4
4
  title?: string;
5
5
  description?: string;
6
+ /**
7
+ * The card's own section heading.
8
+ *
9
+ * The header band has three ways in, and they resolve in this order:
10
+ * `header` wins over everything, then `heading`, then `title`. A caller who
11
+ * hands a snippet has already decided what the band contains, so the
12
+ * component adds nothing to it. `heading` renders the icon, a real heading
13
+ * element at the level the caller names, and the meta row that seven
14
+ * hand-rolled section shells wrote out for themselves. `title` predates it
15
+ * and still renders exactly what it always did, so the cards already
16
+ * shipping do not move. Set one of the three.
17
+ */
18
+ heading?: string;
19
+ /** 2 under a page title, 3 inside another card. */
20
+ headingLevel?: 2 | 3;
21
+ /** Drawn before the heading. Decorative, so it carries no name of its own. */
22
+ icon?: Component<{
23
+ size?: number;
24
+ class?: string;
25
+ }>;
26
+ /** A count, a status or a timestamp, at the end of the heading row. */
27
+ meta?: string;
6
28
  pad?: Pad;
7
29
  hover?: boolean;
8
30
  class?: string;
@@ -12,6 +34,6 @@ interface Props {
12
34
  onkeydown?: (e: KeyboardEvent) => void;
13
35
  children: Snippet;
14
36
  }
15
- declare const Card: import("svelte").Component<Props, {}, "">;
37
+ declare const Card: Component<Props, {}, "">;
16
38
  type Card = ReturnType<typeof Card>;
17
39
  export default Card;