@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
@@ -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,19 +31,43 @@
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 { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
40
+ import { safeHref } from '../internal/href.js';
28
41
  import { HIT_AREA } from '../internal/touch.js';
29
42
 
30
43
  interface Props {
31
- /** The pressed chip's value. */
44
+ /** The pressed chip's value. In link mode, the value of the current chip. */
32
45
  value: T;
33
46
  options: FilterChip<T>[];
34
47
  /** Names the group for assistive technology. The chips name themselves. */
35
48
  label: string;
36
49
  /** Read after the count, so "3" is heard as "3 on sale". */
37
50
  countLabel?: string;
51
+ /**
52
+ * The group's id. The hint and the error take theirs from it. Left unset,
53
+ * the group carries no id and the messages take a generated one.
54
+ */
55
+ id?: string;
56
+ /** Shown under the row while there is no error. */
57
+ hint?: string;
58
+ /**
59
+ * Replaces the hint while it is set. The group points at it and is not
60
+ * marked invalid, because ARIA refuses that attribute on a group.
61
+ */
62
+ error?: string;
63
+ /**
64
+ * Ids of other elements that describe the group, separated by spaces, such
65
+ * as the message a wrapping Field renders. They are read after the group's
66
+ * own hint or error.
67
+ */
68
+ describedBy?: string;
38
69
  class?: string;
70
+ /** Button mode only. A link chip reports nothing. The page it opens does. */
39
71
  onchange?: (value: T) => void;
40
72
  }
41
73
 
@@ -44,43 +76,117 @@
44
76
  options,
45
77
  label,
46
78
  countLabel = '',
79
+ id = undefined,
80
+ hint = undefined,
81
+ error = undefined,
82
+ describedBy: callerIds = undefined,
47
83
  class: klass = '',
48
84
  onchange = undefined,
49
85
  }: Props = $props();
50
86
 
87
+ /*
88
+ * Link mode when every chip carries an href. A row that mixes the two is
89
+ * refused outright, for the reason SegmentedControl refuses one: a button
90
+ * beside two links would report a choice that the URL never learns about,
91
+ * and the caller has no way to see that from the screen.
92
+ */
93
+ const linked = $derived.by(() => {
94
+ const count = options.filter((o) => o.href !== undefined).length;
95
+ if (count !== 0 && count !== options.length) {
96
+ throw new Error(
97
+ `FilterChips: every option carries href or none does, ${count} of ${options.length} did`,
98
+ );
99
+ }
100
+ return count !== 0;
101
+ });
102
+
103
+ /*
104
+ * $props.id() and not Math.random(), so the message ids match between the
105
+ * server render and hydration. The group carries an id only when the caller
106
+ * names one, so a row given none keeps the attributes it had.
107
+ */
108
+ const uid = $props.id();
109
+ const messageBase = $derived(id ?? uid);
110
+ const describedByIds = $derived(joinIds(describedBy(messageBase, error, hint), callerIds));
111
+
51
112
  function pick(next: T): void {
52
113
  if (next === value) return;
53
114
  value = next;
54
115
  onchange?.(next);
55
116
  }
117
+
118
+ /** One treatment for both modes, so a current link reads as a pressed chip. */
119
+ function chipClass(pressed: boolean): string {
120
+ return cn(
121
+ HIT_AREA,
122
+ 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors',
123
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
124
+ pressed
125
+ ? 'border-brand bg-brand/10 text-fg'
126
+ : 'border-line text-muted hover:border-line-strong hover:text-fg',
127
+ );
128
+ }
56
129
  </script>
57
130
 
58
- <div class={cn('flex flex-wrap gap-1.5', klass)} role="group" aria-label={label}>
131
+ <!--
132
+ The row is rendered once and the message follows it as a sibling. A column
133
+ wrapped round the two only while a message showed rebuilt the row each time
134
+ an error arrived or cleared, and a chip that held focus dropped it to the
135
+ page. The row carries the field marker while it reports a message, so a
136
+ panel sizing itself to its form counts it as the one field it then is.
137
+
138
+ The row carries no aria-invalid. ARIA does not allow it on a group, or on
139
+ the buttons and links inside one, so an error reaches a reader through
140
+ aria-describedby, which reads the message itself.
141
+ -->
142
+ <div
143
+ data-field={error || hint ? '' : undefined}
144
+ {id}
145
+ class={cn('flex flex-wrap gap-1.5', klass)}
146
+ role="group"
147
+ aria-label={label}
148
+ aria-describedby={describedByIds}
149
+ >
59
150
  {#each options as chip (chip.value)}
60
151
  {@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>
152
+ {#if linked}
153
+ <!-- A plain anchor with no click handler, so choosing a chip is a
154
+ navigation the browser performs on its own, before the page has
155
+ hydrated as well as after. -->
156
+ <a
157
+ href={safeHref(chip.href)}
158
+ aria-current={pressed ? 'page' : undefined}
159
+ class={chipClass(pressed)}
160
+ >
161
+ {@render face(chip)}
162
+ </a>
163
+ {:else}
164
+ <button
165
+ type="button"
166
+ aria-pressed={pressed}
167
+ class={chipClass(pressed)}
168
+ onclick={() => pick(chip.value)}
169
+ >
170
+ {@render face(chip)}
171
+ </button>
172
+ {/if}
85
173
  {/each}
86
174
  </div>
175
+ {#if error}
176
+ <p id="{messageBase}-error" class="{FIELD_ERROR} mt-1.5">{error}</p>
177
+ {:else if hint}
178
+ <p id="{messageBase}-hint" class="{FIELD_HINT} mt-1.5">{hint}</p>
179
+ {/if}
180
+
181
+ {#snippet face(chip: FilterChip<T>)}
182
+ {#if chip.icon}
183
+ {@const Icon = chip.icon}
184
+ <Icon size={12} class="shrink-0" />
185
+ {/if}
186
+ {chip.label}
187
+ {#if chip.count !== undefined}
188
+ <span class="font-mono text-[10px] text-faint"
189
+ >{chip.count}{#if countLabel}<span class="sr-only"> {countLabel}</span>{/if}</span
190
+ >
191
+ {/if}
192
+ {/snippet}
@@ -12,17 +12,44 @@ 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. */
22
30
  label: string;
23
31
  /** Read after the count, so "3" is heard as "3 on sale". */
24
32
  countLabel?: string;
33
+ /**
34
+ * The group's id. The hint and the error take theirs from it. Left unset,
35
+ * the group carries no id and the messages take a generated one.
36
+ */
37
+ id?: string;
38
+ /** Shown under the row while there is no error. */
39
+ hint?: string;
40
+ /**
41
+ * Replaces the hint while it is set. The group points at it and is not
42
+ * marked invalid, because ARIA refuses that attribute on a group.
43
+ */
44
+ error?: string;
45
+ /**
46
+ * Ids of other elements that describe the group, separated by spaces, such
47
+ * as the message a wrapping Field renders. They are read after the group's
48
+ * own hint or error.
49
+ */
50
+ describedBy?: string;
25
51
  class?: string;
52
+ /** Button mode only. A link chip reports nothing. The page it opens does. */
26
53
  onchange?: (value: T) => void;
27
54
  };
28
55
  exports: {};
@@ -7,6 +7,7 @@
7
7
  FIELD_WRAP,
8
8
  controlBorder,
9
9
  describedBy,
10
+ joinIds,
10
11
  } from '../internal/field.js';
11
12
  import type { HTMLInputAttributes } from 'svelte/elements';
12
13
 
@@ -27,6 +28,12 @@
27
28
  placeholder?: string;
28
29
  hint?: string;
29
30
  error?: string;
31
+ /**
32
+ * Ids of other elements that describe the input, separated by spaces, such
33
+ * as the message a wrapping Field renders. They are read after the input's
34
+ * own hint or error.
35
+ */
36
+ describedBy?: string;
30
37
  disabled?: boolean;
31
38
  required?: boolean;
32
39
  id?: string;
@@ -47,6 +54,7 @@
47
54
  placeholder = '',
48
55
  hint = undefined,
49
56
  error = undefined,
57
+ describedBy: callerIds = undefined,
50
58
  disabled = false,
51
59
  required = false,
52
60
  id = undefined,
@@ -73,7 +81,7 @@
73
81
  {required}
74
82
  bind:value
75
83
  aria-invalid={error ? 'true' : undefined}
76
- aria-describedby={describedBy(fieldId, error, hint)}
84
+ aria-describedby={joinIds(describedBy(fieldId, error, hint), callerIds)}
77
85
  class="{CONTROL_BASE} {controlBorder(!!error)} {mono ? 'font-mono' : ''}"
78
86
  {...rest}
79
87
  />
@@ -12,6 +12,12 @@ interface Props extends Forwarded {
12
12
  placeholder?: string;
13
13
  hint?: string;
14
14
  error?: string;
15
+ /**
16
+ * Ids of other elements that describe the input, separated by spaces, such
17
+ * as the message a wrapping Field renders. They are read after the input's
18
+ * own hint or error.
19
+ */
20
+ describedBy?: string;
15
21
  disabled?: boolean;
16
22
  required?: boolean;
17
23
  id?: string;
@@ -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;
@@ -33,7 +33,7 @@
33
33
  type ChoiceSize,
34
34
  type ChoiceVariant,
35
35
  } from '../internal/choice.js';
36
- import { FIELD_ERROR, FIELD_HINT, describedBy } from '../internal/field.js';
36
+ import { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
37
37
  import Radio from './Radio.svelte';
38
38
 
39
39
  interface Props {
@@ -48,8 +48,19 @@
48
48
  label: string;
49
49
  /** Keeps the legend for a screen reader and takes it off the screen. */
50
50
  labelHidden?: boolean;
51
+ /**
52
+ * The fieldset's id. The hint and the error take theirs from it. Left
53
+ * unset, the fieldset carries no id and the messages take a generated one.
54
+ */
55
+ id?: string;
51
56
  hint?: string;
52
57
  error?: string;
58
+ /**
59
+ * Ids of other elements that describe the group, separated by spaces, such
60
+ * as the message a wrapping Field renders. They are read after the group's
61
+ * own hint or error.
62
+ */
63
+ describedBy?: string;
53
64
  name?: string;
54
65
  orientation?: ChoiceOrientation;
55
66
  size?: ChoiceSize;
@@ -65,8 +76,10 @@
65
76
  options,
66
77
  label,
67
78
  labelHidden = false,
79
+ id = undefined,
68
80
  hint = undefined,
69
81
  error = undefined,
82
+ describedBy: callerIds = undefined,
70
83
  name = undefined,
71
84
  orientation = 'vertical',
72
85
  size = 'md',
@@ -87,6 +100,7 @@
87
100
  */
88
101
  const uid = $props.id();
89
102
  const groupName = $derived(name ?? uid);
103
+ const messageBase = $derived(id ?? uid);
90
104
  </script>
91
105
 
92
106
  <!--
@@ -97,12 +111,13 @@
97
111
  -->
98
112
  <fieldset
99
113
  data-field
114
+ {id}
100
115
  class="{CHOICE_GROUP} {cls}"
101
116
  role="radiogroup"
102
117
  {disabled}
103
118
  aria-invalid={error ? 'true' : undefined}
104
119
  aria-required={required ? 'true' : undefined}
105
- aria-describedby={describedBy(uid, error, hint)}
120
+ aria-describedby={joinIds(describedBy(messageBase, error, hint), callerIds)}
106
121
  >
107
122
  <!--
108
123
  The legend stays a legend when it is hidden. Swapping it for an aria-label
@@ -139,8 +154,8 @@
139
154
  </div>
140
155
 
141
156
  {#if error}
142
- <p id="{uid}-error" class={FIELD_ERROR}>{error}</p>
157
+ <p id="{messageBase}-error" class={FIELD_ERROR}>{error}</p>
143
158
  {:else if hint}
144
- <p id="{uid}-hint" class={FIELD_HINT}>{hint}</p>
159
+ <p id="{messageBase}-hint" class={FIELD_HINT}>{hint}</p>
145
160
  {/if}
146
161
  </fieldset>
@@ -36,8 +36,19 @@ interface Props {
36
36
  label: string;
37
37
  /** Keeps the legend for a screen reader and takes it off the screen. */
38
38
  labelHidden?: boolean;
39
+ /**
40
+ * The fieldset's id. The hint and the error take theirs from it. Left
41
+ * unset, the fieldset carries no id and the messages take a generated one.
42
+ */
43
+ id?: string;
39
44
  hint?: string;
40
45
  error?: string;
46
+ /**
47
+ * Ids of other elements that describe the group, separated by spaces, such
48
+ * as the message a wrapping Field renders. They are read after the group's
49
+ * own hint or error.
50
+ */
51
+ describedBy?: string;
41
52
  name?: string;
42
53
  orientation?: ChoiceOrientation;
43
54
  size?: ChoiceSize;
@@ -21,8 +21,16 @@
21
21
  </script>
22
22
 
23
23
  <script lang="ts" generics="T extends string">
24
- import { FIELD_LABEL, FIELD_WRAP } from '../internal/field.js';
24
+ import {
25
+ FIELD_ERROR,
26
+ FIELD_HINT,
27
+ FIELD_LABEL,
28
+ FIELD_WRAP,
29
+ describedBy,
30
+ joinIds,
31
+ } from '../internal/field.js';
25
32
  import { safeHref } from '../internal/href.js';
33
+ import { HIT_AREA } from '../internal/touch.js';
26
34
 
27
35
  type Size = 'sm' | 'md';
28
36
 
@@ -38,6 +46,24 @@
38
46
  label: string;
39
47
  /** Drops the caption above the row. The group keeps its accessible name. */
40
48
  labelHidden?: boolean;
49
+ /**
50
+ * The group's id. The hint and the error take theirs from it. Left unset,
51
+ * the group carries no id and the messages take a generated one.
52
+ */
53
+ id?: string;
54
+ /** Shown under the row while there is no error. */
55
+ hint?: string;
56
+ /**
57
+ * Replaces the hint while it is set. A row of radios is marked invalid
58
+ * with it. A row of links is not, because ARIA refuses the attribute there.
59
+ */
60
+ error?: string;
61
+ /**
62
+ * Ids of other elements that describe the group, separated by spaces, such
63
+ * as the message a wrapping Field renders. They are read after the group's
64
+ * own hint or error.
65
+ */
66
+ describedBy?: string;
41
67
  /** Submits the value with the surrounding form, through a hidden input. */
42
68
  name?: string;
43
69
  size?: Size;
@@ -52,6 +78,10 @@
52
78
  options,
53
79
  label,
54
80
  labelHidden = false,
81
+ id = undefined,
82
+ hint = undefined,
83
+ error = undefined,
84
+ describedBy: callerIds = undefined,
55
85
  name = undefined,
56
86
  size = 'md',
57
87
  disabled = false,
@@ -64,12 +94,26 @@
64
94
  * Input in the same row line up. The segments stretch inside it rather than
65
95
  * setting their own height, which would add the row's padding and border on
66
96
  * top and stand the control two pixels proud of everything beside it.
97
+ *
98
+ * Under a finger the small row grows to the control height, as a small
99
+ * Button does, and each segment keeps its visual inside the track and takes
100
+ * a hit box of the control size. A segment that grew instead would stand
101
+ * the track six pixels proud of the Input beside it.
67
102
  */
68
103
  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 },
104
+ sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
70
105
  md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
71
106
  };
72
107
 
108
+ /*
109
+ * $props.id() and not Math.random(), so the message ids match between the
110
+ * server render and hydration. The group carries an id only when the caller
111
+ * names one, so a control given none keeps the attributes it had.
112
+ */
113
+ const uid = $props.id();
114
+ const messageBase = $derived(id ?? uid);
115
+ const describedByIds = $derived(joinIds(describedBy(messageBase, error, hint), callerIds));
116
+
73
117
  let rootEl: HTMLDivElement | undefined = $state();
74
118
 
75
119
  /*
@@ -182,21 +226,26 @@
182
226
 
183
227
  <!-- A set of links is a group, not a radiogroup: a link is not a radio and
184
228
  carries no aria-checked, and what marks the current one is aria-current,
185
- the attribute a reader already knows from navigation. -->
229
+ the attribute a reader already knows from navigation. ARIA allows
230
+ aria-invalid on a radiogroup and not on a group, so a row of links
231
+ reports an error through aria-describedby alone. -->
186
232
  <div
187
233
  bind:this={rootEl}
234
+ {id}
188
235
  role={linked ? 'group' : 'radiogroup'}
189
236
  aria-label={label}
237
+ aria-invalid={error && !linked ? 'true' : undefined}
238
+ aria-describedby={describedByIds}
190
239
  onfocusout={linked ? onGroupFocusOut : undefined}
191
- class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
192
- size
193
- ].row} {disabled ? 'opacity-50' : ''}"
240
+ class="inline-flex items-stretch rounded-lg border {error
241
+ ? 'border-danger'
242
+ : 'border-line-strong'} bg-surface-2 p-0.5 {SIZES[size].row} {disabled ? 'opacity-50' : ''}"
194
243
  >
195
244
  {#each options as option, i (option.value)}
196
245
  {@const selected = option.value === value}
197
246
  {@const Icon = option.icon}
198
247
  {@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 ' +
248
+ `${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
249
  SIZES[size].segment +
201
250
  (selected
202
251
  ? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
@@ -261,6 +310,12 @@
261
310
  {/each}
262
311
  </div>
263
312
 
313
+ {#if error}
314
+ <p id="{messageBase}-error" class={FIELD_ERROR}>{error}</p>
315
+ {:else if hint}
316
+ <p id="{messageBase}-hint" class={FIELD_HINT}>{hint}</p>
317
+ {/if}
318
+
264
319
  {#if name}
265
320
  <!-- A div with role="radiogroup" submits nothing. The value reaches a form
266
321
  post through this and not through the segments, which are buttons and
@@ -32,6 +32,24 @@ declare function $$render<T extends string>(): {
32
32
  label: string;
33
33
  /** Drops the caption above the row. The group keeps its accessible name. */
34
34
  labelHidden?: boolean;
35
+ /**
36
+ * The group's id. The hint and the error take theirs from it. Left unset,
37
+ * the group carries no id and the messages take a generated one.
38
+ */
39
+ id?: string;
40
+ /** Shown under the row while there is no error. */
41
+ hint?: string;
42
+ /**
43
+ * Replaces the hint while it is set. A row of radios is marked invalid
44
+ * with it. A row of links is not, because ARIA refuses the attribute there.
45
+ */
46
+ error?: string;
47
+ /**
48
+ * Ids of other elements that describe the group, separated by spaces, such
49
+ * as the message a wrapping Field renders. They are read after the group's
50
+ * own hint or error.
51
+ */
52
+ describedBy?: string;
35
53
  /** Submits the value with the surrounding form, through a hidden input. */
36
54
  name?: string;
37
55
  size?: "sm" | "md";