@lyeve-labs/ui-kit 0.36.0 → 0.37.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 CHANGED
@@ -236,6 +236,116 @@ 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
+ ### Copying
263
+
264
+ `CopyButton` takes `text` for words beside its icon, such as "Copy all codes",
265
+ and the words then name the button. A control the kit does not ship copies
266
+ through the same write and confirms on the same clock with `CopyState`. Create
267
+ one while the component initializes, read `status` in markup, and dispose of it
268
+ when the component unmounts.
269
+
270
+ ```svelte
271
+ <script lang="ts">
272
+ import { Button, CopyState } from '@lyeve-labs/ui-kit';
273
+
274
+ let { id }: { id: string } = $props();
275
+ const copy = new CopyState();
276
+ $effect(() => () => copy.dispose());
277
+ </script>
278
+
279
+ <Button variant="ghost" size="sm" onclick={() => copy.copy(id)}>
280
+ {copy.status === 'copied' ? 'Copied' : 'Copy ID'}
281
+ </Button>
282
+ ```
283
+
284
+ `copyToClipboard(text)` is the write alone. It resolves to whether the copy
285
+ worked and never throws. Where the async clipboard is missing, as it is on an
286
+ origin without TLS, it falls back to the legacy copy command.
287
+
288
+ ### Page titles
289
+
290
+ `PageShell` renders the document title once an app states its format, in its
291
+ root layout. A page then drops its own title element, which would otherwise
292
+ race the shell's. `documentTitle` gives the tab other words than the heading.
293
+ A format that reads reactive state, such as a tenant's name, follows it. An app
294
+ that states no format gets no title element from the kit.
295
+
296
+ ```svelte
297
+ <!-- src/routes/+layout.svelte -->
298
+ <script lang="ts">
299
+ import { setDocumentTitleFormat } from '@lyeve-labs/ui-kit';
300
+
301
+ let { children } = $props();
302
+ setDocumentTitleFormat((title) => `${title} | Acme`);
303
+ </script>
304
+
305
+ {@render children()}
306
+ ```
307
+
308
+ ```svelte
309
+ <PageShell title="Edit entry" documentTitle={entry.name}>...</PageShell>
310
+ ```
311
+
312
+ ### A sign-in page in a tenant's colors
313
+
314
+ `AuthShell` takes a `lockup` snippet that replaces the product mark above the
315
+ heading, and passes `class` and `style` to its outer element. Custom properties
316
+ set there scope to the page, so a tenant's accent repaints the brand tokens for
317
+ everything on it. Inside the `href` link the lockup names the link, so give it
318
+ words or an image with alt text.
319
+
320
+ ```svelte
321
+ <AuthShell title="Sign in" href="/" style="--color-brand: {tenant.accent}">
322
+ {#snippet lockup()}
323
+ <span class="inline-flex items-center gap-2 text-xl font-semibold text-fg">
324
+ <img src={tenant.logo} alt="" class="size-9" />
325
+ {tenant.name}
326
+ </span>
327
+ {/snippet}
328
+ <form method="POST">...</form>
329
+ </AuthShell>
330
+ ```
331
+
332
+ ### Tone names
333
+
334
+ `StatusTone` is the tone `Alert`, `Banner` and `Stat` take. `AccentTone` adds
335
+ `violet` and is the tone `Badge`, `Tag`, `Indicator` and `Progress` take. An app
336
+ that maps its own states onto tones types the map with them.
337
+
338
+ ```ts
339
+ import type { StatusTone } from '@lyeve-labs/ui-kit';
340
+
341
+ const JOB_TONE: Record<JobState, StatusTone> = {
342
+ queued: 'neutral',
343
+ running: 'brand',
344
+ failed: 'danger',
345
+ done: 'success',
346
+ };
347
+ ```
348
+
239
349
  ## Local development
240
350
 
241
351
  ```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, {}, "">;
@@ -6,8 +6,17 @@
6
6
  interface Props {
7
7
  /** The text written to the clipboard. */
8
8
  value: string;
9
- /** The button's accessible name. */
9
+ /**
10
+ * The accessible name of a button that shows only its icon. A button with
11
+ * `text` takes its name from that text instead.
12
+ */
10
13
  label?: string;
14
+ /**
15
+ * Words shown beside the icon, such as "Copy all codes". They name the
16
+ * button, so the name a voice-control user speaks is the one on screen.
17
+ * Left unset, the button is the icon alone.
18
+ */
19
+ text?: string;
11
20
  /** Announced and shown after a successful copy. */
12
21
  copiedLabel?: string;
13
22
  /** Icon size in px. */
@@ -18,11 +27,20 @@
18
27
  let {
19
28
  value,
20
29
  label = 'Copy',
30
+ text = undefined,
21
31
  copiedLabel = 'Copied',
22
32
  size = 14,
23
33
  class: klass = '',
24
34
  }: Props = $props();
25
35
 
36
+ /*
37
+ * The icon alone sits at the faint step beside the text it copies. Words
38
+ * read as an action of their own, so they take the muted step the kit sets
39
+ * other text actions in, and the padding a word needs around it.
40
+ */
41
+ 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`;
42
+ 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`;
43
+
26
44
  const copier = new CopyState();
27
45
 
28
46
  const message = $derived.by(() => {
@@ -45,18 +63,18 @@
45
63
  -->
46
64
  <span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
47
65
  <!--
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.
66
+ The accessible name stays the same through the copied state, whether it
67
+ comes from `label` or from `text`. The live region below is what reports the
68
+ result, and renaming the button as well would announce the same word twice
69
+ and then leave a control called "Copied" that copies.
52
70
  -->
53
71
  <button
54
72
  type="button"
55
- aria-label={label}
73
+ aria-label={text ? undefined : label}
56
74
  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"
75
+ class={text ? WITH_TEXT : ICON_ONLY}
58
76
  >
59
- <CopyGlyph copied={copier.status === 'copied'} {size} />
77
+ <CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
60
78
  </button>
61
79
 
62
80
  <span
@@ -1,8 +1,17 @@
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;
6
15
  /** Announced and shown after a successful copy. */
7
16
  copiedLabel?: string;
8
17
  /** Icon size in px. */
@@ -11,6 +11,14 @@
11
11
  count?: number;
12
12
  /** A lucide component before the label. */
13
13
  icon?: Component<{ size?: number; class?: string }>;
14
+ /**
15
+ * Renders the chip as a link to this URL instead of a button. Every chip
16
+ * carries one or none does. The modes cannot be mixed in one row. The
17
+ * current chip is still the one whose value matches `value`, and it
18
+ * carries aria-current="page", so a page can keep its filter in the URL
19
+ * and the row works before any script has loaded.
20
+ */
21
+ href?: string;
14
22
  }
15
23
  </script>
16
24
 
@@ -23,12 +31,16 @@
23
31
  * scrolls, hiding categories past the edge, or squeezes each label onto two
24
32
  * lines inside its segment. Chips wrap instead, so every category is in
25
33
  * sight at every width. One is always pressed.
34
+ *
35
+ * With an href on every chip the row is a set of links instead, for a
36
+ * filter the page keeps in its URL.
26
37
  */
27
38
  import { cn } from '../utils/cn.js';
39
+ import { safeHref } from '../internal/href.js';
28
40
  import { HIT_AREA } from '../internal/touch.js';
29
41
 
30
42
  interface Props {
31
- /** The pressed chip's value. */
43
+ /** The pressed chip's value. In link mode, the value of the current chip. */
32
44
  value: T;
33
45
  options: FilterChip<T>[];
34
46
  /** Names the group for assistive technology. The chips name themselves. */
@@ -36,6 +48,7 @@
36
48
  /** Read after the count, so "3" is heard as "3 on sale". */
37
49
  countLabel?: string;
38
50
  class?: string;
51
+ /** Button mode only. A link chip reports nothing. The page it opens does. */
39
52
  onchange?: (value: T) => void;
40
53
  }
41
54
 
@@ -48,39 +61,77 @@
48
61
  onchange = undefined,
49
62
  }: Props = $props();
50
63
 
64
+ /*
65
+ * Link mode when every chip carries an href. A row that mixes the two is
66
+ * refused outright, for the reason SegmentedControl refuses one: a button
67
+ * beside two links would report a choice that the URL never learns about,
68
+ * and the caller has no way to see that from the screen.
69
+ */
70
+ const linked = $derived.by(() => {
71
+ const count = options.filter((o) => o.href !== undefined).length;
72
+ if (count !== 0 && count !== options.length) {
73
+ throw new Error(
74
+ `FilterChips: every option carries href or none does, ${count} of ${options.length} did`,
75
+ );
76
+ }
77
+ return count !== 0;
78
+ });
79
+
51
80
  function pick(next: T): void {
52
81
  if (next === value) return;
53
82
  value = next;
54
83
  onchange?.(next);
55
84
  }
85
+
86
+ /** One treatment for both modes, so a current link reads as a pressed chip. */
87
+ function chipClass(pressed: boolean): string {
88
+ return cn(
89
+ HIT_AREA,
90
+ 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
91
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
92
+ pressed
93
+ ? 'border-brand bg-brand/10 text-fg'
94
+ : 'border-line text-muted hover:border-line-strong hover:text-fg',
95
+ );
96
+ }
56
97
  </script>
57
98
 
58
99
  <div class={cn('flex flex-wrap gap-1.5', klass)} role="group" aria-label={label}>
59
100
  {#each options as chip (chip.value)}
60
101
  {@const pressed = chip.value === value}
61
- <button
62
- type="button"
63
- aria-pressed={pressed}
64
- class={cn(
65
- HIT_AREA,
66
- 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
67
- 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
68
- pressed
69
- ? 'border-brand bg-brand/10 text-fg'
70
- : 'border-line text-muted hover:border-line-strong hover:text-fg',
71
- )}
72
- onclick={() => pick(chip.value)}
73
- >
74
- {#if chip.icon}
75
- {@const Icon = chip.icon}
76
- <Icon size={12} class="shrink-0" />
77
- {/if}
78
- {chip.label}
79
- {#if chip.count !== undefined}
80
- <span class="font-mono text-[10px] text-faint"
81
- >{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
82
- >
83
- {/if}
84
- </button>
102
+ {#if linked}
103
+ <!-- A plain anchor with no click handler, so choosing a chip is a
104
+ navigation the browser performs on its own, before the page has
105
+ hydrated as well as after. -->
106
+ <a
107
+ href={safeHref(chip.href)}
108
+ aria-current={pressed ? 'page' : undefined}
109
+ class={chipClass(pressed)}
110
+ >
111
+ {@render face(chip)}
112
+ </a>
113
+ {:else}
114
+ <button
115
+ type="button"
116
+ aria-pressed={pressed}
117
+ class={chipClass(pressed)}
118
+ onclick={() => pick(chip.value)}
119
+ >
120
+ {@render face(chip)}
121
+ </button>
122
+ {/if}
85
123
  {/each}
86
124
  </div>
125
+
126
+ {#snippet face(chip: FilterChip<T>)}
127
+ {#if chip.icon}
128
+ {@const Icon = chip.icon}
129
+ <Icon size={12} class="shrink-0" />
130
+ {/if}
131
+ {chip.label}
132
+ {#if chip.count !== undefined}
133
+ <span class="font-mono text-[10px] text-faint"
134
+ >{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
135
+ >
136
+ {/if}
137
+ {/snippet}
@@ -12,10 +12,18 @@ export interface FilterChip<T extends string = string> {
12
12
  size?: number;
13
13
  class?: string;
14
14
  }>;
15
+ /**
16
+ * Renders the chip as a link to this URL instead of a button. Every chip
17
+ * carries one or none does. The modes cannot be mixed in one row. The
18
+ * current chip is still the one whose value matches `value`, and it
19
+ * carries aria-current="page", so a page can keep its filter in the URL
20
+ * and the row works before any script has loaded.
21
+ */
22
+ href?: string;
15
23
  }
16
24
  declare function $$render<T extends string>(): {
17
25
  props: {
18
- /** The pressed chip's value. */
26
+ /** The pressed chip's value. In link mode, the value of the current chip. */
19
27
  value: T;
20
28
  options: FilterChip<T>[];
21
29
  /** Names the group for assistive technology. The chips name themselves. */
@@ -23,6 +31,7 @@ declare function $$render<T extends string>(): {
23
31
  /** Read after the count, so "3" is heard as "3 on sale". */
24
32
  countLabel?: string;
25
33
  class?: string;
34
+ /** Button mode only. A link chip reports nothing. The page it opens does. */
26
35
  onchange?: (value: T) => void;
27
36
  };
28
37
  exports: {};
@@ -7,6 +7,7 @@
7
7
  controlBorder,
8
8
  describedBy,
9
9
  } from '../internal/field.js';
10
+ import { HIT_AREA } from '../internal/touch.js';
10
11
 
11
12
  interface Props {
12
13
  value?: number;
@@ -70,8 +71,13 @@
70
71
  // line-strong, not line: the stepper buttons and the number between them read
71
72
  // as one control, and line sits at 1.25:1, so the caps of that control were
72
73
  // the part a low-vision user could not find.
74
+ //
75
+ // Under a finger the control token grows to 44px, and steppers as wide as the
76
+ // token left the number 40px inside a 128px field. They narrow instead and
77
+ // take a hit box of the control size, so the number keeps its room and each
78
+ // stepper is still a 44px target.
73
79
  const step_ =
74
- 'flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted ' +
80
+ `${HIT_AREA} flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted coarse:w-8 ` +
75
81
  'transition-colors hover:bg-line hover:text-fg ' +
76
82
  'disabled:opacity-50 disabled:cursor-not-allowed';
77
83
  </script>
@@ -13,17 +13,32 @@
13
13
  * The shell renders no landmark of its own. An app shell already owns `main`,
14
14
  * `nav` and the rest, and a second `main` changes what a landmark query
15
15
  * matches for every assistive technology reading the page.
16
+ *
17
+ * The browser tab follows the page title once the app states a format with
18
+ * setDocumentTitleFormat. Until it does, the shell renders no title element,
19
+ * so a page that renders its own keeps it.
16
20
  */
17
21
  import type { Snippet } from 'svelte';
18
22
  import { ArrowLeft } from '@lucide/svelte';
23
+ import { getDocumentTitleFormat } from '../internal/document-title.js';
19
24
  import { safeHref } from '../internal/href.js';
20
25
  import { PAGE_PAD, PAGE_STACK, PAGE_WIDTH, type PageWidth } from '../internal/layout.js';
21
26
  import { HIT_AREA } from '../internal/touch.js';
22
27
  import PageHeader from './PageHeader.svelte';
23
28
 
24
29
  interface Props {
25
- /** The page's only h1, rendered through PageHeader. */
30
+ /**
31
+ * The page's only h1, rendered through PageHeader. Also the browser tab's
32
+ * title, once the app states a format.
33
+ */
26
34
  title: string;
35
+ /**
36
+ * What the browser tab shows when it should read differently from
37
+ * `title`, such as a record's name on a page whose heading says what is
38
+ * done to it. Read only once the app states a format with
39
+ * setDocumentTitleFormat.
40
+ */
41
+ documentTitle?: string;
27
42
  description?: string;
28
43
  /** Content cap. narrow, default, wide, full. */
29
44
  width?: PageWidth;
@@ -77,6 +92,7 @@
77
92
 
78
93
  let {
79
94
  title,
95
+ documentTitle = undefined,
80
96
  description = undefined,
81
97
  width = 'default',
82
98
  fill = false,
@@ -89,6 +105,11 @@
89
105
  children,
90
106
  }: Props = $props();
91
107
 
108
+ const format = getDocumentTitleFormat();
109
+
110
+ /** The tab's words, or nothing at all while the app has stated no format. */
111
+ const tabTitle = $derived(format === undefined ? undefined : format(documentTitle ?? title));
112
+
92
113
  /**
93
114
  * A fill page owns the viewport instead of sitting in it: the content takes
94
115
  * no cap and reaches the edges, and the height the title row leaves goes to
@@ -132,6 +153,12 @@
132
153
  'focus-visible:ring-2 focus-visible:ring-brand';
133
154
  </script>
134
155
 
156
+ <svelte:head>
157
+ {#if tabTitle !== undefined}
158
+ <title>{tabTitle}</title>
159
+ {/if}
160
+ </svelte:head>
161
+
135
162
  <div class="{frame} {klass}">
136
163
  {#if titleHidden}
137
164
  <h1 data-testid="page-title" class="sr-only">{title}</h1>
@@ -12,12 +12,26 @@
12
12
  * The shell renders no landmark of its own. An app shell already owns `main`,
13
13
  * `nav` and the rest, and a second `main` changes what a landmark query
14
14
  * matches for every assistive technology reading the page.
15
+ *
16
+ * The browser tab follows the page title once the app states a format with
17
+ * setDocumentTitleFormat. Until it does, the shell renders no title element,
18
+ * so a page that renders its own keeps it.
15
19
  */
16
20
  import type { Snippet } from 'svelte';
17
21
  import { type PageWidth } from '../internal/layout.js';
18
22
  interface Props {
19
- /** The page's only h1, rendered through PageHeader. */
23
+ /**
24
+ * The page's only h1, rendered through PageHeader. Also the browser tab's
25
+ * title, once the app states a format.
26
+ */
20
27
  title: string;
28
+ /**
29
+ * What the browser tab shows when it should read differently from
30
+ * `title`, such as a record's name on a page whose heading says what is
31
+ * done to it. Read only once the app states a format with
32
+ * setDocumentTitleFormat.
33
+ */
34
+ documentTitle?: string;
21
35
  description?: string;
22
36
  /** Content cap. narrow, default, wide, full. */
23
37
  width?: PageWidth;
@@ -23,6 +23,7 @@
23
23
  <script lang="ts" generics="T extends string">
24
24
  import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
25
25
  import { safeHref } from '../internal/href.js';
26
+ import { HIT_AREA } from '../internal/touch.js';
26
27
 
27
28
  type Size = 'sm' | 'md';
28
29
 
@@ -64,9 +65,14 @@
64
65
  * Input in the same row line up. The segments stretch inside it rather than
65
66
  * setting their own height, which would add the row's padding and border on
66
67
  * top and stand the control two pixels proud of everything beside it.
68
+ *
69
+ * Under a finger the small row grows to the control height, as a small
70
+ * Button does, and each segment keeps its visual inside the track and takes
71
+ * a hit box of the control size. A segment that grew instead would stand
72
+ * the track six pixels proud of the Input beside it.
67
73
  */
68
74
  const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
69
- sm: { row: 'h-8', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
75
+ sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
70
76
  md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
71
77
  };
72
78
 
@@ -196,7 +202,7 @@
196
202
  {@const selected = option.value === value}
197
203
  {@const Icon = option.icon}
198
204
  {@const segment =
199
- 'inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ' +
205
+ `${HIT_AREA} inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
200
206
  SIZES[size].segment +
201
207
  (selected
202
208
  ? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
@@ -166,7 +166,14 @@
166
166
  {@render body(node)}
167
167
  </span>
168
168
  {:else if node.href && branch}
169
- <div class="flex items-center gap-1">
169
+ <!--
170
+ The rail stacks the disclosure under the link, under a mouse and under
171
+ a finger alike, so the link keeps the rail's width. Side by side the two
172
+ did not fit. Under a finger the pair took 92px and scrolled the nav
173
+ sideways. Under a mouse the link shrank to its own padding with its icon
174
+ under the chevron, and a rail with a gutter scrolled as well.
175
+ -->
176
+ <div class={collapsed ? 'flex flex-col items-start gap-1' : 'flex items-center gap-1'}>
170
177
  <a
171
178
  href={node.href}
172
179
  aria-current={current ? 'page' : undefined}
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export type { CellFit } from './components/Table.svelte';
25
25
  export type { SidebarState } from './components/AppShell.svelte';
26
26
  export type { PageWidth, SectionVariant } from './internal/layout.js';
27
27
  export type { TreeNode, TriState } from './internal/tree.js';
28
+ export { setDocumentTitleFormat, type DocumentTitleFormat } from './internal/document-title.js';
28
29
  export { default as Button } from './components/Button.svelte';
29
30
  export { default as ButtonGroup } from './components/ButtonGroup.svelte';
30
31
  export { default as Input } from './components/Input.svelte';
@@ -79,10 +80,12 @@ export { default as CheckoutSteps } from './components/CheckoutSteps.svelte';
79
80
  export { default as PluginCart } from './components/PluginCart.svelte';
80
81
  export type { QuickPick } from './components/PluginCart.svelte';
81
82
  export { billedCount, formatCents, ladderRows, ladderState, type BillingPeriod, type CartCatalog, type CartPlugin, type CartQuote, type CartQuoteItem, type DiscountBracket, type LadderState, } from './internal/pricing.js';
83
+ export type { AccentTone, StatusTone } from './internal/tone.js';
82
84
  export { default as Stat } from './components/Stat.svelte';
83
85
  export { default as Kbd } from './components/Kbd.svelte';
84
86
  export { default as CopyButton } from './components/CopyButton.svelte';
85
87
  export { default as CopyField } from './components/CopyField.svelte';
88
+ export { CopyState, copyToClipboard, type CopyStatus } from './internal/copy.svelte.js';
86
89
  export { default as Avatar } from './components/Avatar.svelte';
87
90
  export { default as AvatarGroup } from './components/AvatarGroup.svelte';
88
91
  export { default as Logo } from './components/Logo.svelte';
@@ -100,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
100
103
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
101
104
  export * as motion from './motion.js';
102
105
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
103
- export declare const VERSION = "0.36.0";
106
+ export declare const VERSION = "0.37.0";
package/dist/index.js CHANGED
@@ -22,6 +22,10 @@ export { default as Table } from './components/Table.svelte';
22
22
  export { default as DescriptionList } from './components/DescriptionList.svelte';
23
23
  export { default as Toolbar } from './components/Toolbar.svelte';
24
24
  export { default as TreeView } from './components/TreeView.svelte';
25
+ // Stated once in an app's root layout, the format turns every PageShell's
26
+ // title into the browser tab's. An app that never states one gets no title
27
+ // element from the kit.
28
+ export { setDocumentTitleFormat } from './internal/document-title.js';
25
29
  // ── Forms & inputs ─────────────────────────────────────────────────────────
26
30
  export { default as Button } from './components/Button.svelte';
27
31
  export { default as ButtonGroup } from './components/ButtonGroup.svelte';
@@ -79,6 +83,10 @@ export { default as Stat } from './components/Stat.svelte';
79
83
  export { default as Kbd } from './components/Kbd.svelte';
80
84
  export { default as CopyButton } from './components/CopyButton.svelte';
81
85
  export { default as CopyField } from './components/CopyField.svelte';
86
+ // The clipboard write and the confirmation both copy controls run on, for a
87
+ // control the kit does not ship. A menu item that copies then falls back the
88
+ // same way on an insecure origin and confirms on the same clock.
89
+ export { CopyState, copyToClipboard } from './internal/copy.svelte.js';
82
90
  // ── Media ──────────────────────────────────────────────────────────────────
83
91
  export { default as Avatar } from './components/Avatar.svelte';
84
92
  export { default as AvatarGroup } from './components/AvatarGroup.svelte';
@@ -108,4 +116,4 @@ export * as motion from './motion.js';
108
116
  // ── Version ────────────────────────────────────────────────────────────────
109
117
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
110
118
  // this line. The build and the test suite fail when the two disagree.
111
- export const VERSION = '0.36.0';
119
+ export const VERSION = '0.37.0';
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Both controls report the same three states on the same clock, so a value
5
5
  * copied from a field and one copied from a code block confirm the same way.
6
+ * The package entry point exports the write and the state too, so a control
7
+ * the kit does not ship confirms the same way as well.
6
8
  */
9
+ /** Where the last copy stands: at rest, confirmed, or refused. */
7
10
  export type CopyStatus = 'idle' | 'copied' | 'failed';
8
11
  /** How long the confirmation holds before the control returns to rest. */
9
12
  export declare const COPY_REVERT_MS = 1500;
@@ -16,9 +19,17 @@ export declare const COPY_FAILED_MESSAGE = "Copy failed";
16
19
  * writeText rejects while the document is not focused. Either way the value
17
20
  * goes through a selected, off-screen textarea and the legacy copy command,
18
21
  * which the browser still honors inside the click. Only when that also
19
- * refuses is it a failure the control reports. It never throws.
22
+ * refuses does it resolve to false. It never throws.
23
+ */
24
+ export declare function copyToClipboard(value: string): Promise<boolean>;
25
+ /**
26
+ * A copy and its outcome, on the clock CopyButton and CopyField confirm on.
27
+ *
28
+ * `status` reads `copied` or `failed` for 1.5 seconds after `copy` and `idle`
29
+ * otherwise. It is reactive, so markup that reads it follows it. Create one
30
+ * per control while the component initializes, and call `dispose` when that
31
+ * component unmounts, so the revert timer cannot fire after it.
20
32
  */
21
- export declare function writeClipboard(value: string): Promise<boolean>;
22
33
  export declare class CopyState {
23
34
  #private;
24
35
  status: CopyStatus;
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Both controls report the same three states on the same clock, so a value
5
5
  * copied from a field and one copied from a code block confirm the same way.
6
+ * The package entry point exports the write and the state too, so a control
7
+ * the kit does not ship confirms the same way as well.
6
8
  */
7
9
  /** How long the confirmation holds before the control returns to rest. */
8
10
  export const COPY_REVERT_MS = 1500;
@@ -15,9 +17,9 @@ export const COPY_FAILED_MESSAGE = 'Copy failed';
15
17
  * writeText rejects while the document is not focused. Either way the value
16
18
  * goes through a selected, off-screen textarea and the legacy copy command,
17
19
  * which the browser still honors inside the click. Only when that also
18
- * refuses is it a failure the control reports. It never throws.
20
+ * refuses does it resolve to false. It never throws.
19
21
  */
20
- export async function writeClipboard(value) {
22
+ export async function copyToClipboard(value) {
21
23
  const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
22
24
  if (clipboard) {
23
25
  try {
@@ -50,6 +52,14 @@ function legacyCopy(value) {
50
52
  area.remove();
51
53
  }
52
54
  }
55
+ /**
56
+ * A copy and its outcome, on the clock CopyButton and CopyField confirm on.
57
+ *
58
+ * `status` reads `copied` or `failed` for 1.5 seconds after `copy` and `idle`
59
+ * otherwise. It is reactive, so markup that reads it follows it. Create one
60
+ * per control while the component initializes, and call `dispose` when that
61
+ * component unmounts, so the revert timer cannot fire after it.
62
+ */
53
63
  export class CopyState {
54
64
  status = $state('idle');
55
65
  #timer;
@@ -57,7 +67,7 @@ export class CopyState {
57
67
  // A second press restarts the window. Without this the first press's timer
58
68
  // reverts the second copy part way through its own.
59
69
  clearTimeout(this.#timer);
60
- this.status = (await writeClipboard(value)) ? 'copied' : 'failed';
70
+ this.status = (await copyToClipboard(value)) ? 'copied' : 'failed';
61
71
  this.#timer = setTimeout(() => (this.status = 'idle'), COPY_REVERT_MS);
62
72
  }
63
73
  dispose() {
@@ -0,0 +1,11 @@
1
+ /** Turns a page's name into the words its browser tab shows. */
2
+ export type DocumentTitleFormat = (title: string) => string;
3
+ /**
4
+ * States the format every PageShell below the calling component renders its
5
+ * document title in. Call it while a layout initializes, the way any context
6
+ * is set. A format that reads reactive state is run again when that state
7
+ * changes.
8
+ */
9
+ export declare function setDocumentTitleFormat(format: DocumentTitleFormat): void;
10
+ /** The format a layout above stated, or undefined when none did. */
11
+ export declare function getDocumentTitleFormat(): DocumentTitleFormat | undefined;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The words a browser tab shows for a page, stated once for a whole app.
3
+ *
4
+ * A console rendered its own title element on every page, and each page spelled
5
+ * the suffix after the page name for itself. PageShell already holds the page's
6
+ * name, so an app that states the format once in its root layout gets every
7
+ * PageShell page titled the same way. An app that states none gets no title
8
+ * from the kit at all, and its pages keep whatever they render themselves.
9
+ *
10
+ * Context rather than a module variable: one server process renders pages for
11
+ * every visitor, and a format that reads the request (a tenant's name, say)
12
+ * would otherwise leak from one render into the next.
13
+ */
14
+ import { getContext, setContext } from 'svelte';
15
+ const FORMAT = Symbol('document-title-format');
16
+ /**
17
+ * States the format every PageShell below the calling component renders its
18
+ * document title in. Call it while a layout initializes, the way any context
19
+ * is set. A format that reads reactive state is run again when that state
20
+ * changes.
21
+ */
22
+ export function setDocumentTitleFormat(format) {
23
+ setContext(FORMAT, format);
24
+ }
25
+ /** The format a layout above stated, or undefined when none did. */
26
+ export function getDocumentTitleFormat() {
27
+ return getContext(FORMAT);
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",