@lyeve-labs/ui-kit 0.37.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.
package/README.md CHANGED
@@ -259,13 +259,50 @@ two throws.
259
259
  />
260
260
  ```
261
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
+
262
294
  ### Copying
263
295
 
264
296
  `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.
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.
269
306
 
270
307
  ```svelte
271
308
  <script lang="ts">
@@ -2,6 +2,7 @@
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. */
@@ -17,6 +18,13 @@
17
18
  * Left unset, the button is the icon alone.
18
19
  */
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;
20
28
  /** Announced and shown after a successful copy. */
21
29
  copiedLabel?: string;
22
30
  /** Icon size in px. */
@@ -28,6 +36,7 @@
28
36
  value,
29
37
  label = 'Copy',
30
38
  text = undefined,
39
+ title = undefined,
31
40
  copiedLabel = 'Copied',
32
41
  size = 14,
33
42
  class: klass = '',
@@ -50,6 +59,14 @@
50
59
  });
51
60
 
52
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));
53
70
  </script>
54
71
 
55
72
  <!--
@@ -62,11 +79,26 @@
62
79
  of its icon and a row of them lines up.
63
80
  -->
64
81
  <span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
82
+ {#if title}
83
+ <Tooltip text={title} describe={describes}>{@render button()}</Tooltip>
84
+ {:else}
85
+ {@render button()}
86
+ {/if}
87
+
88
+ <span
89
+ role="status"
90
+ aria-live="polite"
91
+ aria-atomic="true"
92
+ class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
93
+ >
94
+ </span>
95
+
96
+ {#snippet button()}
65
97
  <!--
66
98
  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.
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.
70
102
  -->
71
103
  <button
72
104
  type="button"
@@ -76,11 +108,4 @@
76
108
  >
77
109
  <CopyGlyph copied={copier.status === 'copied'} {size} />{#if text}{text}{/if}
78
110
  </button>
79
-
80
- <span
81
- role="status"
82
- aria-live="polite"
83
- aria-atomic="true"
84
- class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
85
- >
86
- </span>
111
+ {/snippet}
@@ -12,6 +12,13 @@ interface Props {
12
12
  * Left unset, the button is the icon alone.
13
13
  */
14
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;
15
22
  /** Announced and shown after a successful copy. */
16
23
  copiedLabel?: string;
17
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;
@@ -36,6 +36,7 @@
36
36
  * filter the page keeps in its URL.
37
37
  */
38
38
  import { cn } from '../utils/cn.js';
39
+ import { FIELD_ERROR, FIELD_HINT, describedBy, joinIds } from '../internal/field.js';
39
40
  import { safeHref } from '../internal/href.js';
40
41
  import { HIT_AREA } from '../internal/touch.js';
41
42
 
@@ -47,6 +48,24 @@
47
48
  label: string;
48
49
  /** Read after the count, so "3" is heard as "3 on sale". */
49
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;
50
69
  class?: string;
51
70
  /** Button mode only. A link chip reports nothing. The page it opens does. */
52
71
  onchange?: (value: T) => void;
@@ -57,6 +76,10 @@
57
76
  options,
58
77
  label,
59
78
  countLabel = '',
79
+ id = undefined,
80
+ hint = undefined,
81
+ error = undefined,
82
+ describedBy: callerIds = undefined,
60
83
  class: klass = '',
61
84
  onchange = undefined,
62
85
  }: Props = $props();
@@ -77,6 +100,15 @@
77
100
  return count !== 0;
78
101
  });
79
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
+
80
112
  function pick(next: T): void {
81
113
  if (next === value) return;
82
114
  value = next;
@@ -96,7 +128,25 @@
96
128
  }
97
129
  </script>
98
130
 
99
- <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
+ >
100
150
  {#each options as chip (chip.value)}
101
151
  {@const pressed = chip.value === value}
102
152
  {#if linked}
@@ -122,6 +172,11 @@
122
172
  {/if}
123
173
  {/each}
124
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}
125
180
 
126
181
  {#snippet face(chip: FilterChip<T>)}
127
182
  {#if chip.icon}
@@ -30,6 +30,24 @@ declare function $$render<T extends string>(): {
30
30
  label: string;
31
31
  /** Read after the count, so "3" is heard as "3 on sale". */
32
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;
33
51
  class?: string;
34
52
  /** Button mode only. A link chip reports nothing. The page it opens does. */
35
53
  onchange?: (value: T) => void;
@@ -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;
@@ -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,7 +21,14 @@
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';
26
33
  import { HIT_AREA } from '../internal/touch.js';
27
34
 
@@ -39,6 +46,24 @@
39
46
  label: string;
40
47
  /** Drops the caption above the row. The group keeps its accessible name. */
41
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;
42
67
  /** Submits the value with the surrounding form, through a hidden input. */
43
68
  name?: string;
44
69
  size?: Size;
@@ -53,6 +78,10 @@
53
78
  options,
54
79
  label,
55
80
  labelHidden = false,
81
+ id = undefined,
82
+ hint = undefined,
83
+ error = undefined,
84
+ describedBy: callerIds = undefined,
56
85
  name = undefined,
57
86
  size = 'md',
58
87
  disabled = false,
@@ -76,6 +105,15 @@
76
105
  md: { row: 'h-control', segment: 'gap-2 px-3 text-sm', icon: 16 },
77
106
  };
78
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
+
79
117
  let rootEl: HTMLDivElement | undefined = $state();
80
118
 
81
119
  /*
@@ -188,15 +226,20 @@
188
226
 
189
227
  <!-- A set of links is a group, not a radiogroup: a link is not a radio and
190
228
  carries no aria-checked, and what marks the current one is aria-current,
191
- 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. -->
192
232
  <div
193
233
  bind:this={rootEl}
234
+ {id}
194
235
  role={linked ? 'group' : 'radiogroup'}
195
236
  aria-label={label}
237
+ aria-invalid={error && !linked ? 'true' : undefined}
238
+ aria-describedby={describedByIds}
196
239
  onfocusout={linked ? onGroupFocusOut : undefined}
197
- class="inline-flex items-stretch rounded-lg border border-line-strong bg-surface-2 p-0.5 {SIZES[
198
- size
199
- ].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' : ''}"
200
243
  >
201
244
  {#each options as option, i (option.value)}
202
245
  {@const selected = option.value === value}
@@ -267,6 +310,12 @@
267
310
  {/each}
268
311
  </div>
269
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
+
270
319
  {#if name}
271
320
  <!-- A div with role="radiogroup" submits nothing. The value reaches a form
272
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";
@@ -53,6 +53,7 @@
53
53
  FIELD_WRAP,
54
54
  controlBorder,
55
55
  describedBy,
56
+ joinIds,
56
57
  } from '../internal/field.js';
57
58
  import { createListbox } from '../internal/listbox.svelte.js';
58
59
  import {
@@ -75,6 +76,12 @@
75
76
  required?: boolean;
76
77
  disabled?: boolean;
77
78
  error?: string;
79
+ /**
80
+ * Ids of other elements that describe the control, separated by spaces,
81
+ * such as the message a wrapping Field renders. They are read after the
82
+ * control's own hint or error.
83
+ */
84
+ describedBy?: string;
78
85
  class?: string;
79
86
  /** The rows, as data. */
80
87
  options?: SelectOption[];
@@ -102,6 +109,7 @@
102
109
  required = false,
103
110
  disabled = false,
104
111
  error,
112
+ describedBy: callerIds,
105
113
  class: cls = '',
106
114
  options = [],
107
115
  searchable = false,
@@ -312,7 +320,7 @@
312
320
  }}
313
321
  aria-invalid={error ? 'true' : undefined}
314
322
  aria-required={required ? 'true' : undefined}
315
- aria-describedby={describedBy(fieldId, error, hint)}
323
+ aria-describedby={joinIds(describedBy(fieldId, error, hint), callerIds)}
316
324
  {...box.triggerAttrs}
317
325
  class="{CONTROL_BASE} {controlBorder(!!error)} flex cursor-pointer items-center gap-2 pe-8
318
326
  text-start"
@@ -53,6 +53,12 @@ interface Props {
53
53
  required?: boolean;
54
54
  disabled?: boolean;
55
55
  error?: string;
56
+ /**
57
+ * Ids of other elements that describe the control, separated by spaces,
58
+ * such as the message a wrapping Field renders. They are read after the
59
+ * control's own hint or error.
60
+ */
61
+ describedBy?: string;
56
62
  class?: string;
57
63
  /** The rows, as data. */
58
64
  options?: SelectOption[];
package/dist/index.d.ts CHANGED
@@ -103,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
103
103
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
104
104
  export * as motion from './motion.js';
105
105
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
106
- export declare const VERSION = "0.37.0";
106
+ export declare const VERSION = "0.38.0";
package/dist/index.js CHANGED
@@ -116,4 +116,4 @@ export * as motion from './motion.js';
116
116
  // ── Version ────────────────────────────────────────────────────────────────
117
117
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
118
118
  // this line. The build and the test suite fail when the two disagree.
119
- export const VERSION = '0.37.0';
119
+ export const VERSION = '0.38.0';
@@ -105,3 +105,11 @@ export declare function segmentedBorder(error: boolean): string;
105
105
  * by the wrapper's own rounding.
106
106
  */
107
107
  export declare const CONTROL_SEGMENT: string;
108
+ /**
109
+ * Joins a control's own message id with the ids its caller names.
110
+ *
111
+ * A control inside a Field, or explained by text elsewhere on the page, has to
112
+ * point at that text as well as at its own hint or error. Returns undefined
113
+ * when there is nothing to point at, for the reason describedBy does.
114
+ */
115
+ export declare function joinIds(...ids: (string | undefined)[]): string | undefined;
@@ -134,3 +134,13 @@ export function segmentedBorder(error) {
134
134
  export const CONTROL_SEGMENT = 'shrink-0 rounded-sm border-0 bg-transparent p-0 text-center text-sm text-fg tabular-nums ' +
135
135
  'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand ' +
136
136
  'disabled:cursor-not-allowed';
137
+ /**
138
+ * Joins a control's own message id with the ids its caller names.
139
+ *
140
+ * A control inside a Field, or explained by text elsewhere on the page, has to
141
+ * point at that text as well as at its own hint or error. Returns undefined
142
+ * when there is nothing to point at, for the reason describedBy does.
143
+ */
144
+ export function joinIds(...ids) {
145
+ return ids.filter(Boolean).join(' ') || undefined;
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.37.0",
3
+ "version": "0.38.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>",