@lyeve-labs/ui-kit 0.36.0 → 0.38.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 (32) hide show
  1. package/README.md +147 -0
  2. package/dist/components/AuthShell.svelte +25 -2
  3. package/dist/components/AuthShell.svelte.d.ts +13 -0
  4. package/dist/components/CopyButton.svelte +58 -15
  5. package/dist/components/CopyButton.svelte.d.ts +17 -1
  6. package/dist/components/CopyField.svelte +58 -15
  7. package/dist/components/CopyField.svelte.d.ts +10 -0
  8. package/dist/components/EmptyState.svelte +14 -1
  9. package/dist/components/EmptyState.svelte.d.ts +6 -0
  10. package/dist/components/FilterChips.svelte +132 -26
  11. package/dist/components/FilterChips.svelte.d.ts +28 -1
  12. package/dist/components/Input.svelte +9 -1
  13. package/dist/components/Input.svelte.d.ts +6 -0
  14. package/dist/components/NumberInput.svelte +7 -1
  15. package/dist/components/PageShell.svelte +28 -1
  16. package/dist/components/PageShell.svelte.d.ts +15 -1
  17. package/dist/components/RadioGroup.svelte +19 -4
  18. package/dist/components/RadioGroup.svelte.d.ts +11 -0
  19. package/dist/components/SegmentedControl.svelte +62 -7
  20. package/dist/components/SegmentedControl.svelte.d.ts +18 -0
  21. package/dist/components/Select.svelte +9 -1
  22. package/dist/components/Select.svelte.d.ts +6 -0
  23. package/dist/components/SidebarNav.svelte +8 -1
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.js +9 -1
  26. package/dist/internal/copy.svelte.d.ts +13 -2
  27. package/dist/internal/copy.svelte.js +13 -3
  28. package/dist/internal/document-title.d.ts +11 -0
  29. package/dist/internal/document-title.js +28 -0
  30. package/dist/internal/field.d.ts +8 -0
  31. package/dist/internal/field.js +10 -0
  32. package/package.json +1 -1
package/README.md CHANGED
@@ -236,6 +236,153 @@ the fields the panel actually rendered, re-read when the form reveals more, and
236
236
  a radio or checkbox group counts as the one question it asks. Name a rung and
237
237
  it is kept.
238
238
 
239
+ ## Component notes
240
+
241
+ What a component does beyond its props table.
242
+
243
+ ### A filter kept in the URL
244
+
245
+ `SegmentedControl` and `FilterChips` take an `href` on each option. When every
246
+ option carries one, the options render as plain links: the current one carries
247
+ `aria-current="page"`, no change is reported, and the row works before the page
248
+ hydrates. Every option carries an `href` or none does, and a row that mixes the
249
+ two throws.
250
+
251
+ ```svelte
252
+ <FilterChips
253
+ label="Status"
254
+ value={status}
255
+ options={[
256
+ { value: 'all', label: 'All', href: '?status=all' },
257
+ { value: 'open', label: 'Open', count: 7, href: '?status=open' },
258
+ ]}
259
+ />
260
+ ```
261
+
262
+ ### Hints and errors on a choice row
263
+
264
+ `SegmentedControl` and `FilterChips` take `id`, `hint`, `error` and
265
+ `describedBy`, the field props `Input`, `Select` and `RadioGroup` take. The
266
+ hint shows under the row, and an error replaces it. The group points
267
+ `aria-describedby` at whichever is on screen, then at the ids in
268
+ `describedBy`, so a row inside a `Field` reads that field's message too. A
269
+ segmented row of radios carries `aria-invalid` while it shows an error. ARIA
270
+ does not allow that attribute on a group, so a row of chips or links reports
271
+ its error through the description alone. Both link modes take the same props.
272
+
273
+ `SegmentedControl` draws its own caption from `label`. Inside a `Field`, which
274
+ draws the label already, pass `labelHidden` so the name shows once and the
275
+ group keeps it for a screen reader. `FilterChips` draws no caption, and its
276
+ `label` only names the group.
277
+
278
+ ```svelte
279
+ <SegmentedControl
280
+ label="Billing period"
281
+ options={periods}
282
+ bind:value={period}
283
+ hint="Charged at the start of each period."
284
+ error={form?.errors?.period}
285
+ />
286
+ ```
287
+
288
+ ### Empty states
289
+
290
+ `EmptyState` renders its title as an h3, for a list inside a section. On a
291
+ page whose only other heading is its h1, pass `level={2}` so the outline does
292
+ not skip a level. The title looks the same at either level.
293
+
294
+ ### Copying
295
+
296
+ `CopyButton` takes `text` for words beside its icon, such as "Copy all codes",
297
+ and the words then name the button. `title` adds a tooltip, shown on hover and
298
+ on keyboard focus. It never renames the button, and when it says more than the
299
+ name it becomes the button's description. `CopyField` shows its value on one
300
+ truncated line, and `wrap` shows the whole value across as many lines as it
301
+ takes, breaking anywhere, for a key the reader has to check end to end.
302
+
303
+ A control the kit does not ship copies through the same write and confirms on
304
+ the same clock with `CopyState`. Create one while the component initializes,
305
+ read `status` in markup, and dispose of it when the component unmounts.
306
+
307
+ ```svelte
308
+ <script lang="ts">
309
+ import { Button, CopyState } from '@lyeve-labs/ui-kit';
310
+
311
+ let { id }: { id: string } = $props();
312
+ const copy = new CopyState();
313
+ $effect(() => () => copy.dispose());
314
+ </script>
315
+
316
+ <Button variant="ghost" size="sm" onclick={() => copy.copy(id)}>
317
+ {copy.status === 'copied' ? 'Copied' : 'Copy ID'}
318
+ </Button>
319
+ ```
320
+
321
+ `copyToClipboard(text)` is the write alone. It resolves to whether the copy
322
+ worked and never throws. Where the async clipboard is missing, as it is on an
323
+ origin without TLS, it falls back to the legacy copy command.
324
+
325
+ ### Page titles
326
+
327
+ `PageShell` renders the document title once an app states its format, in its
328
+ root layout. A page then drops its own title element, which would otherwise
329
+ race the shell's. `documentTitle` gives the tab other words than the heading.
330
+ A format that reads reactive state, such as a tenant's name, follows it. An app
331
+ that states no format gets no title element from the kit.
332
+
333
+ ```svelte
334
+ <!-- src/routes/+layout.svelte -->
335
+ <script lang="ts">
336
+ import { setDocumentTitleFormat } from '@lyeve-labs/ui-kit';
337
+
338
+ let { children } = $props();
339
+ setDocumentTitleFormat((title) => `${title} | Acme`);
340
+ </script>
341
+
342
+ {@render children()}
343
+ ```
344
+
345
+ ```svelte
346
+ <PageShell title="Edit entry" documentTitle={entry.name}>...</PageShell>
347
+ ```
348
+
349
+ ### A sign-in page in a tenant's colors
350
+
351
+ `AuthShell` takes a `lockup` snippet that replaces the product mark above the
352
+ heading, and passes `class` and `style` to its outer element. Custom properties
353
+ set there scope to the page, so a tenant's accent repaints the brand tokens for
354
+ everything on it. Inside the `href` link the lockup names the link, so give it
355
+ words or an image with alt text.
356
+
357
+ ```svelte
358
+ <AuthShell title="Sign in" href="/" style="--color-brand: {tenant.accent}">
359
+ {#snippet lockup()}
360
+ <span class="inline-flex items-center gap-2 text-xl font-semibold text-fg">
361
+ <img src={tenant.logo} alt="" class="size-9" />
362
+ {tenant.name}
363
+ </span>
364
+ {/snippet}
365
+ <form method="POST">...</form>
366
+ </AuthShell>
367
+ ```
368
+
369
+ ### Tone names
370
+
371
+ `StatusTone` is the tone `Alert`, `Banner` and `Stat` take. `AccentTone` adds
372
+ `violet` and is the tone `Badge`, `Tag`, `Indicator` and `Progress` take. An app
373
+ that maps its own states onto tones types the map with them.
374
+
375
+ ```ts
376
+ import type { StatusTone } from '@lyeve-labs/ui-kit';
377
+
378
+ const JOB_TONE: Record<JobState, StatusTone> = {
379
+ queued: 'neutral',
380
+ running: 'brand',
381
+ failed: 'danger',
382
+ done: 'success',
383
+ };
384
+ ```
385
+
239
386
  ## Local development
240
387
 
241
388
  ```bash
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
3
4
  import Card from './Card.svelte';
4
5
  import Logo from './Logo.svelte';
5
6
 
@@ -13,10 +14,23 @@
13
14
  href?: string;
14
15
  /** `md` is a form. `lg` is a walkthrough with more than one column. */
15
16
  width?: Width;
17
+ /**
18
+ * Drawn above the heading in place of the product mark, such as a
19
+ * tenant's logo and name. Inside the `href` link it names that link, so
20
+ * it carries words or an image with alt text.
21
+ */
22
+ lockup?: Snippet;
16
23
  /** Controls at the top end of the column: a theme toggle, a language switch. */
17
24
  actions?: Snippet;
18
25
  /** Under the card: the one link that leads off the page, such as "Create an account". */
19
26
  footer?: Snippet;
27
+ /** Added to the page's outer element. */
28
+ class?: string;
29
+ /**
30
+ * Set on the page's outer element, so custom properties scope to the
31
+ * page: a tenant's accent repaints the brand tokens for everything inside.
32
+ */
33
+ style?: string;
20
34
  children: Snippet;
21
35
  }
22
36
 
@@ -25,8 +39,11 @@
25
39
  description = undefined,
26
40
  href = undefined,
27
41
  width = 'md',
42
+ lockup = undefined,
28
43
  actions = undefined,
29
44
  footer = undefined,
45
+ class: klass = '',
46
+ style = undefined,
30
47
  children,
31
48
  }: Props = $props();
32
49
 
@@ -42,7 +59,7 @@
42
59
  on some pages, a theme control on others, and one class that named no
43
60
  token at all. Stated once here, the way AppShell states the signed-in frame.
44
61
  -->
45
- <main class="flex min-h-screen items-center justify-center bg-ink px-4 py-10">
62
+ <main class={cn('flex min-h-screen items-center justify-center bg-ink px-4 py-10', klass)} {style}>
46
63
  <div class="w-full {widths[width]}">
47
64
  {#if actions}
48
65
  <div class="mb-4 flex justify-end">{@render actions()}</div>
@@ -54,8 +71,14 @@
54
71
  {href}
55
72
  class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
56
73
  >
57
- <Logo size="lg" />
74
+ {#if lockup}
75
+ {@render lockup()}
76
+ {:else}
77
+ <Logo size="lg" />
78
+ {/if}
58
79
  </a>
80
+ {:else if lockup}
81
+ <div class="mb-5 flex justify-center">{@render lockup()}</div>
59
82
  {:else}
60
83
  <Logo size="lg" class="mb-5" />
61
84
  {/if}
@@ -8,10 +8,23 @@ interface Props {
8
8
  href?: string;
9
9
  /** `md` is a form. `lg` is a walkthrough with more than one column. */
10
10
  width?: Width;
11
+ /**
12
+ * Drawn above the heading in place of the product mark, such as a
13
+ * tenant's logo and name. Inside the `href` link it names that link, so
14
+ * it carries words or an image with alt text.
15
+ */
16
+ lockup?: Snippet;
11
17
  /** Controls at the top end of the column: a theme toggle, a language switch. */
12
18
  actions?: Snippet;
13
19
  /** Under the card: the one link that leads off the page, such as "Create an account". */
14
20
  footer?: Snippet;
21
+ /** Added to the page's outer element. */
22
+ class?: string;
23
+ /**
24
+ * Set on the page's outer element, so custom properties scope to the
25
+ * page: a tenant's accent repaints the brand tokens for everything inside.
26
+ */
27
+ style?: string;
15
28
  children: Snippet;
16
29
  }
17
30
  declare const AuthShell: import("svelte").Component<Props, {}, "">;
@@ -2,12 +2,29 @@
2
2
  import { HIT_AREA } from '../internal/touch.js';
3
3
  import CopyGlyph from '../internal/CopyGlyph.svelte';
4
4
  import { COPY_FAILED_MESSAGE, CopyState } from '../internal/copy.svelte.js';
5
+ import Tooltip from './Tooltip.svelte';
5
6
 
6
7
  interface Props {
7
8
  /** The text written to the clipboard. */
8
9
  value: string;
9
- /** The button's accessible name. */
10
+ /**
11
+ * The accessible name of a button that shows only its icon. A button with
12
+ * `text` takes its name from that text instead.
13
+ */
10
14
  label?: string;
15
+ /**
16
+ * Words shown beside the icon, such as "Copy all codes". They name the
17
+ * button, so the name a voice-control user speaks is the one on screen.
18
+ * Left unset, the button is the icon alone.
19
+ */
20
+ text?: string;
21
+ /**
22
+ * A tooltip on the button, shown on hover and on keyboard focus, such as
23
+ * "Copy the signing key". It never renames the button: the name stays
24
+ * `text`, or `label` on an icon alone. When it says more than the name, it
25
+ * becomes the button's accessible description.
26
+ */
27
+ title?: string;
11
28
  /** Announced and shown after a successful copy. */
12
29
  copiedLabel?: string;
13
30
  /** Icon size in px. */
@@ -18,11 +35,21 @@
18
35
  let {
19
36
  value,
20
37
  label = 'Copy',
38
+ text = undefined,
39
+ title = undefined,
21
40
  copiedLabel = 'Copied',
22
41
  size = 14,
23
42
  class: klass = '',
24
43
  }: Props = $props();
25
44
 
45
+ /*
46
+ * The icon alone sits at the faint step beside the text it copies. Words
47
+ * read as an action of their own, so they take the muted step the kit sets
48
+ * other text actions in, and the padding a word needs around it.
49
+ */
50
+ const ICON_ONLY = `${HIT_AREA} inline-flex items-center justify-center rounded-md p-1 text-faint outline-none transition-colors hover:text-fg active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand`;
51
+ const WITH_TEXT = `${HIT_AREA} inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted outline-none transition-colors hover:text-fg active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand`;
52
+
26
53
  const copier = new CopyState();
27
54
 
28
55
  const message = $derived.by(() => {
@@ -32,6 +59,14 @@
32
59
  });
33
60
 
34
61
  $effect(() => () => copier.dispose());
62
+
63
+ /*
64
+ * A tooltip in the same words as the name would be announced twice, as
65
+ * "Copy, Copy", so it is described only when it adds something. Case and
66
+ * outer spaces change nothing a reader hears, so neither counts.
67
+ */
68
+ const spoken = (words: string) => words.trim().toLowerCase();
69
+ const describes = $derived(spoken(title ?? '') !== spoken(text || label));
35
70
  </script>
36
71
 
37
72
  <!--
@@ -44,20 +79,11 @@
44
79
  of its icon and a row of them lines up.
45
80
  -->
46
81
  <span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
47
- <!--
48
- The accessible name stays `label` through the copied copier. The live region
49
- below is what reports the result, and renaming the button as well would
50
- announce the same word twice and then leave a control called "Copied" that
51
- copies.
52
- -->
53
- <button
54
- type="button"
55
- aria-label={label}
56
- onclick={() => copier.copy(value)}
57
- class="{HIT_AREA} inline-flex items-center justify-center rounded-md p-1 text-faint outline-none transition-colors hover:text-fg active:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
58
- >
59
- <CopyGlyph copied={copier.status === 'copied'} {size} />
60
- </button>
82
+ {#if title}
83
+ <Tooltip text={title} describe={describes}>{@render button()}</Tooltip>
84
+ {:else}
85
+ {@render button()}
86
+ {/if}
61
87
 
62
88
  <span
63
89
  role="status"
@@ -66,3 +92,20 @@
66
92
  class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
67
93
  >
68
94
  </span>
95
+
96
+ {#snippet button()}
97
+ <!--
98
+ The accessible name stays the same through the copied state, whether it
99
+ comes from `label` or from `text`. The live region beside it is what
100
+ reports the result, and renaming the button as well would announce the
101
+ same word twice and then leave a control called "Copied" that copies.
102
+ -->
103
+ <button
104
+ type="button"
105
+ aria-label={text ? undefined : label}
106
+ onclick={() => copier.copy(value)}
107
+ class={text ? WITH_TEXT : ICON_ONLY}
108
+ >
109
+ <CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
110
+ </button>
111
+ {/snippet}
@@ -1,8 +1,24 @@
1
1
  interface Props {
2
2
  /** The text written to the clipboard. */
3
3
  value: string;
4
- /** The button's accessible name. */
4
+ /**
5
+ * The accessible name of a button that shows only its icon. A button with
6
+ * `text` takes its name from that text instead.
7
+ */
5
8
  label?: string;
9
+ /**
10
+ * Words shown beside the icon, such as "Copy all codes". They name the
11
+ * button, so the name a voice-control user speaks is the one on screen.
12
+ * Left unset, the button is the icon alone.
13
+ */
14
+ text?: string;
15
+ /**
16
+ * A tooltip on the button, shown on hover and on keyboard focus, such as
17
+ * "Copy the signing key". It never renames the button: the name stays
18
+ * `text`, or `label` on an icon alone. When it says more than the name, it
19
+ * becomes the button's accessible description.
20
+ */
21
+ title?: string;
6
22
  /** Announced and shown after a successful copy. */
7
23
  copiedLabel?: string;
8
24
  /** Icon size in px. */
@@ -18,6 +18,9 @@
18
18
  * `actions` adds the caller's own controls in the same row, before the
19
19
  * reveal and the copy: a rotate, a regenerate, a link out. They sit in the
20
20
  * field's own row, so any number of them fits without covering the value.
21
+ *
22
+ * `wrap` shows the whole value over as many lines as it takes, for a key or
23
+ * an address the reader has to check end to end rather than recognize.
21
24
  */
22
25
  import type { Snippet } from 'svelte';
23
26
  import { Eye, EyeOff } from '@lucide/svelte';
@@ -43,6 +46,13 @@
43
46
  mono?: boolean;
44
47
  /** Mask the value and offer a control that reveals it. */
45
48
  secret?: boolean;
49
+ /**
50
+ * Wraps the whole value across lines instead of showing one truncated
51
+ * line. It breaks anywhere, since a key or a token has no spaces to break
52
+ * at. A masked secret stays on one line, because its dots carry nothing
53
+ * to read, and wraps once revealed.
54
+ */
55
+ wrap?: boolean;
46
56
  /** Extra controls inside the field's end, before the reveal and the copy. */
47
57
  actions?: Snippet;
48
58
  /** The copy control's accessible name. Defaults to "Copy" and the label. */
@@ -60,6 +70,7 @@
60
70
  hint = undefined,
61
71
  mono = true,
62
72
  secret = false,
73
+ wrap = false,
63
74
  actions = undefined,
64
75
  copyLabel = undefined,
65
76
  copiedLabel = 'Copied',
@@ -73,6 +84,7 @@
73
84
  const copier = new CopyState();
74
85
  let revealed = $state(false);
75
86
  const masked = $derived(secret && !revealed);
87
+ const wrapped = $derived(wrap && !masked);
76
88
  const failed = $derived(copier.status === 'failed');
77
89
 
78
90
  $effect(() => () => copier.dispose());
@@ -86,23 +98,54 @@
86
98
  <!-- The frame is the control: the value and its buttons sit in one row
87
99
  inside it, so the caller's actions, the reveal and the copy all fit
88
100
  without the value running under them and without measuring anything.
89
- The frame takes the focus ring and the brand border from the input
90
- inside it. A focused button draws its own ring and not the frame's. -->
101
+ The frame takes the focus ring and the brand border from the input or
102
+ the textarea inside it. A focused button draws its own ring and not the frame's. -->
91
103
  <div
92
- class="flex h-control w-full items-center gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand"
104
+ class="flex w-full gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
105
+ ? 'min-h-control items-start py-1 has-[textarea:focus]:border-brand has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-inset has-[textarea:focus-visible]:ring-brand'
106
+ : 'h-control items-center has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand'}"
93
107
  >
94
- <input
95
- id={fieldId}
96
- type={masked ? 'password' : 'text'}
97
- autocomplete="off"
98
- readonly
99
- {value}
100
- onfocus={(e) => e.currentTarget.select()}
101
- aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
102
- class="h-full min-w-0 flex-1 truncate bg-transparent px-3 text-sm text-fg outline-none {mono
103
- ? 'font-mono'
104
- : ''}"
105
- />
108
+ {#if wrapped}
109
+ <!-- An input holds one line, so the wrapped value is a read-only
110
+ textarea. A textarea does not grow with its text, so it sits in one
111
+ grid cell with an invisible copy of the value, and the copy sets the
112
+ height. The padding and the frame's together keep one line at the
113
+ control height. -->
114
+ <div class="grid min-w-0 flex-1">
115
+ <span
116
+ aria-hidden="true"
117
+ class="invisible col-start-1 row-start-1 px-3 py-1 text-sm break-all whitespace-pre-wrap {mono
118
+ ? 'font-mono'
119
+ : ''}">{value}</span
120
+ >
121
+ <textarea
122
+ id={fieldId}
123
+ rows={1}
124
+ autocomplete="off"
125
+ spellcheck="false"
126
+ readonly
127
+ {value}
128
+ onfocus={(e) => e.currentTarget.select()}
129
+ aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
130
+ class="col-start-1 row-start-1 resize-none overflow-hidden bg-transparent px-3 py-1 text-sm break-all whitespace-pre-wrap text-fg outline-none {mono
131
+ ? 'font-mono'
132
+ : ''}"
133
+ ></textarea>
134
+ </div>
135
+ {:else}
136
+ <input
137
+ id={fieldId}
138
+ type={masked ? 'password' : 'text'}
139
+ autocomplete="off"
140
+ readonly
141
+ {value}
142
+ onfocus={(e) => e.currentTarget.select()}
143
+ aria-describedby={describedBy(fieldId, failed ? COPY_FAILED_MESSAGE : undefined, hint)}
144
+ class="h-full min-w-0 flex-1 truncate bg-transparent px-3 text-sm text-fg outline-none {mono
145
+ ? 'font-mono'
146
+ : ''}"
147
+ />
148
+ {/if}
106
149
  {@render actions?.()}
107
150
  {#if secret}
108
151
  <button
@@ -17,6 +17,9 @@
17
17
  * `actions` adds the caller's own controls in the same row, before the
18
18
  * reveal and the copy: a rotate, a regenerate, a link out. They sit in the
19
19
  * field's own row, so any number of them fits without covering the value.
20
+ *
21
+ * `wrap` shows the whole value over as many lines as it takes, for a key or
22
+ * an address the reader has to check end to end rather than recognize.
20
23
  */
21
24
  import type { Snippet } from 'svelte';
22
25
  interface Props {
@@ -30,6 +33,13 @@ interface Props {
30
33
  mono?: boolean;
31
34
  /** Mask the value and offer a control that reveals it. */
32
35
  secret?: boolean;
36
+ /**
37
+ * Wraps the whole value across lines instead of showing one truncated
38
+ * line. It breaks anywhere, since a key or a token has no spaces to break
39
+ * at. A masked secret stays on one line, because its dots carry nothing
40
+ * to read, and wraps once revealed.
41
+ */
42
+ wrap?: boolean;
33
43
  /** Extra controls inside the field's end, before the reveal and the copy. */
34
44
  actions?: Snippet;
35
45
  /** The copy control's accessible name. Defaults to "Copy" and the label. */
@@ -5,6 +5,12 @@
5
5
  icon?: string;
6
6
  iconSnippet?: Snippet;
7
7
  title: string;
8
+ /**
9
+ * The title's heading level. 3 by default, for a list inside a section. 2
10
+ * on a page whose only other heading is its title, so a screen reader does
11
+ * not meet a jump from h1 to h3.
12
+ */
13
+ level?: 2 | 3;
8
14
  description?: string;
9
15
  class?: string;
10
16
  action?: Snippet;
@@ -15,6 +21,7 @@
15
21
  icon = undefined,
16
22
  iconSnippet = undefined,
17
23
  title,
24
+ level = 3,
18
25
  description = undefined,
19
26
  class: klass = '',
20
27
  action,
@@ -40,7 +47,13 @@
40
47
  {icon}
41
48
  </div>
42
49
  {/if}
43
- <h3 class="text-base font-semibold text-fg">{title}</h3>
50
+ <!-- Two branches rather than one dynamic element, as in SectionHeading:
51
+ a reader of this file can see both headings it can produce. -->
52
+ {#if level === 2}
53
+ <h2 class="text-base font-semibold text-fg">{title}</h2>
54
+ {:else}
55
+ <h3 class="text-base font-semibold text-fg">{title}</h3>
56
+ {/if}
44
57
  {#if description}
45
58
  <p class="mt-1 max-w-sm text-sm text-muted">{description}</p>
46
59
  {/if}
@@ -3,6 +3,12 @@ interface Props {
3
3
  icon?: string;
4
4
  iconSnippet?: Snippet;
5
5
  title: string;
6
+ /**
7
+ * The title's heading level. 3 by default, for a list inside a section. 2
8
+ * on a page whose only other heading is its title, so a screen reader does
9
+ * not meet a jump from h1 to h3.
10
+ */
11
+ level?: 2 | 3;
6
12
  description?: string;
7
13
  class?: string;
8
14
  action?: Snippet;