@lyeve-labs/ui-kit 0.43.0 → 0.44.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.
@@ -33,6 +33,14 @@
33
33
  * being true after five seconds.
34
34
  */
35
35
  autoDismiss?: number | boolean;
36
+ /**
37
+ * Announces the message when it appears, and every change to it after,
38
+ * without clearing it. For a result that stays on screen, such as a
39
+ * confirmation that replaces a form or a status that a poll updates.
40
+ * A message without this or `autoDismiss` states a standing condition
41
+ * and is read out by nobody, so a page load says nothing.
42
+ */
43
+ announce?: boolean;
36
44
  ondismiss?: () => void;
37
45
  class?: string;
38
46
  children?: Snippet;
@@ -43,6 +51,7 @@
43
51
  title = undefined,
44
52
  dismissible = false,
45
53
  autoDismiss = false,
54
+ announce = false,
46
55
  ondismiss = undefined,
47
56
  class: klass = '',
48
57
  children,
@@ -64,6 +73,8 @@
64
73
  * announced every tone assertively, so a green confirmation cut across
65
74
  * whatever was being read. An alert that also clears itself must not be
66
75
  * assertive: it would interrupt to say something and then take it away.
76
+ * The polite branch takes no role: browsers expose role="status" as a status
77
+ * bar, and Orca speaks nothing inserted into one.
67
78
  */
68
79
  const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
69
80
 
@@ -72,6 +83,28 @@
72
83
  /** An alert that can close itself can also be closed by hand. */
73
84
  const closable = $derived(dismissible || delay > 0);
74
85
 
86
+ /**
87
+ * A live region reports a change to what it holds, and an outcome alert
88
+ * arrives inside `{#if form}` already holding its text, so nothing would be
89
+ * read out. An alert that reports an outcome, one that clears itself or
90
+ * takes `announce`, mounts without its text and takes it two frames later,
91
+ * as FormMessage does. It is atomic, as role="status" made it, so a change
92
+ * reads the whole message. One that states a standing condition is no live
93
+ * region at all: it must not be read out because the page loaded, and
94
+ * Chrome reads a polite region that a client-side navigation inserts. A
95
+ * failure or a warning that stays is the alert branch and is never deferred.
96
+ */
97
+ const outcome = $derived(!live && (delay > 0 || announce));
98
+ let filled = $state(typeof window === 'undefined');
99
+
100
+ $effect(() => {
101
+ if (!outcome) return;
102
+ let frame = requestAnimationFrame(() => {
103
+ frame = requestAnimationFrame(() => (filled = true));
104
+ });
105
+ return () => cancelAnimationFrame(frame);
106
+ });
107
+
75
108
  let closed = $state(false);
76
109
  /** The pointer is over the alert, or focus is inside it. */
77
110
  let held = $state(false);
@@ -101,8 +134,9 @@
101
134
  <div
102
135
  transition:motion.toast|global
103
136
  class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
104
- role={live ? 'alert' : 'status'}
105
- aria-live={live ? 'assertive' : 'polite'}
137
+ role={live ? 'alert' : undefined}
138
+ aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
139
+ aria-atomic={outcome ? 'true' : undefined}
106
140
  onmouseenter={closable ? () => (held = true) : undefined}
107
141
  onmouseleave={closable ? () => (held = false) : undefined}
108
142
  onfocusin={closable ? () => (held = true) : undefined}
@@ -127,12 +161,14 @@
127
161
  <path d={TONE_GLYPH[t]} />
128
162
  </svg>
129
163
  </span>
130
- <div class="flex-1 min-w-0">
131
- {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
132
- {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
133
- {@render children()}
134
- </div>{/if}
135
- </div>
164
+ {#if filled || !outcome}
165
+ <div class="flex-1 min-w-0">
166
+ {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
167
+ {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
168
+ {@render children()}
169
+ </div>{/if}
170
+ </div>
171
+ {/if}
136
172
  {#if closable}
137
173
  <button
138
174
  type="button"
@@ -14,6 +14,14 @@ interface Props {
14
14
  * being true after five seconds.
15
15
  */
16
16
  autoDismiss?: number | boolean;
17
+ /**
18
+ * Announces the message when it appears, and every change to it after,
19
+ * without clearing it. For a result that stays on screen, such as a
20
+ * confirmation that replaces a form or a status that a poll updates.
21
+ * A message without this or `autoDismiss` states a standing condition
22
+ * and is read out by nobody, so a page load says nothing.
23
+ */
24
+ announce?: boolean;
17
25
  ondismiss?: () => void;
18
26
  class?: string;
19
27
  children?: Snippet;
@@ -23,6 +23,14 @@
23
23
  * condition, which does not stop being true after five seconds.
24
24
  */
25
25
  autoDismiss?: number | boolean;
26
+ /**
27
+ * Announces the message when it appears, and every change to it after,
28
+ * without clearing it. For a result that stays on screen, such as a
29
+ * confirmation that replaces a form or a status that a poll updates.
30
+ * A message without this or `autoDismiss` states a standing condition
31
+ * and is read out by nobody, so a page load says nothing.
32
+ */
33
+ announce?: boolean;
26
34
  ondismiss?: () => void;
27
35
  class?: string;
28
36
  children: Snippet;
@@ -33,6 +41,7 @@
33
41
  tone = 'brand',
34
42
  dismissible = false,
35
43
  autoDismiss = false,
44
+ announce = false,
36
45
  ondismiss = undefined,
37
46
  class: cls = '',
38
47
  children,
@@ -73,9 +82,32 @@
73
82
  * A failure and a warning interrupt, because the reader has to act, as
74
83
  * Alert does. Anything else waits for a pause. A banner that clears itself
75
84
  * waits too: interrupting to say something and then taking it away leaves
76
- * the reader with nothing to act on.
85
+ * the reader with nothing to act on. The polite branch takes no role, as
86
+ * Alert's does, because Orca speaks nothing inserted into a status role.
77
87
  */
78
88
  const live = $derived((t === 'danger' || t === 'warn') && delay <= 0);
89
+
90
+ /**
91
+ * A live region reports a change to what it holds, and an outcome banner
92
+ * arrives inside `{#if form}` already holding its text, so nothing would be
93
+ * read out. A banner that reports an outcome, one that clears itself or
94
+ * takes `announce`, mounts without its text and takes it two frames later,
95
+ * as FormMessage does. It is atomic, as role="status" made it, so a change
96
+ * reads the whole message. One that states a standing condition is no live
97
+ * region at all: it must not be read out because the page loaded, and
98
+ * Chrome reads a polite region that a client-side navigation inserts. A
99
+ * failure or a warning that stays is the alert branch and is never deferred.
100
+ */
101
+ const outcome = $derived(!live && (delay > 0 || announce));
102
+ let filled = $state(typeof window === 'undefined');
103
+
104
+ $effect(() => {
105
+ if (!outcome) return;
106
+ let frame = requestAnimationFrame(() => {
107
+ frame = requestAnimationFrame(() => (filled = true));
108
+ });
109
+ return () => cancelAnimationFrame(frame);
110
+ });
79
111
  </script>
80
112
 
81
113
  {#if visible}
@@ -85,17 +117,20 @@
85
117
  rather than vanishing in one frame. -->
86
118
  <div
87
119
  transition:motion.toast|global
88
- role={live ? 'alert' : 'status'}
89
- aria-live={live ? 'assertive' : 'polite'}
120
+ role={live ? 'alert' : undefined}
121
+ aria-live={live ? 'assertive' : outcome ? 'polite' : undefined}
122
+ aria-atomic={outcome ? 'true' : undefined}
90
123
  class="relative flex items-center gap-3 border-b px-4 py-3 text-sm {tones[t]} {cls}"
91
124
  onmouseenter={closable ? () => (held = true) : undefined}
92
125
  onmouseleave={closable ? () => (held = false) : undefined}
93
126
  onfocusin={closable ? () => (held = true) : undefined}
94
127
  onfocusout={closable ? () => (held = false) : undefined}
95
128
  >
96
- <div class="flex flex-1 items-center justify-center gap-3 text-fg">
97
- {@render children()}
98
- </div>
129
+ {#if filled || !outcome}
130
+ <div class="flex flex-1 items-center justify-center gap-3 text-fg">
131
+ {@render children()}
132
+ </div>
133
+ {/if}
99
134
 
100
135
  {#if action}
101
136
  <div class="shrink-0">{@render action()}</div>
@@ -12,6 +12,14 @@ interface Props {
12
12
  * condition, which does not stop being true after five seconds.
13
13
  */
14
14
  autoDismiss?: number | boolean;
15
+ /**
16
+ * Announces the message when it appears, and every change to it after,
17
+ * without clearing it. For a result that stays on screen, such as a
18
+ * confirmation that replaces a form or a status that a poll updates.
19
+ * A message without this or `autoDismiss` states a standing condition
20
+ * and is read out by nobody, so a page load says nothing.
21
+ */
22
+ announce?: boolean;
15
23
  ondismiss?: () => void;
16
24
  class?: string;
17
25
  children: Snippet;
@@ -73,7 +73,13 @@
73
73
  The live region is mounted for the life of the button and empty at rest.
74
74
  Rendering it only after a copy announces nothing: assistive technology watches
75
75
  an existing region for a change, and a region that arrives already holding its
76
- text is not a change. Toaster carries the same rule for the same reason.
76
+ text is not a change. Toaster carries the same rule for the same reason. It
77
+ stays a span, so a CopyButton inside a paragraph keeps that paragraph whole.
78
+ It is never role="status", which browsers expose as a status bar and Orca
79
+ never speaks. It is role="generic": Firefox exposes a bare span as static
80
+ text, created only once it holds the message, and Orca drops a sentence
81
+ that comes within a tenth of a second of that arrival unless its region is
82
+ a section, which generic is.
77
83
 
78
84
  The gap exists only while there is a message, so an idle button is the width
79
85
  of its icon and a row of them lines up.
@@ -86,7 +92,7 @@
86
92
  {/if}
87
93
 
88
94
  <span
89
- role="status"
95
+ role="generic"
90
96
  aria-live="polite"
91
97
  aria-atomic="true"
92
98
  class="text-xs {copier.status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
@@ -180,16 +180,16 @@
180
180
 
181
181
  <!-- Mounted empty for the life of the field, so a copy is a change a screen
182
182
  reader announces rather than a region that arrives already full. The
183
- check is the sighted confirmation. Only a failure is shown in words. -->
184
- <p
185
- role="status"
183
+ check is the sighted confirmation. Only a failure is shown in words. A
184
+ div with no role, as Toaster's is, so Orca speaks it. -->
185
+ <div
186
186
  aria-live="polite"
187
187
  aria-atomic="true"
188
188
  id={failed ? `${fieldId}-error` : undefined}
189
189
  class={failed ? FIELD_ERROR : 'sr-only'}
190
190
  >
191
191
  {copier.status === 'copied' ? copiedLabel : failed ? COPY_FAILED_MESSAGE : ''}
192
- </p>
192
+ </div>
193
193
  {#if hint && !failed}
194
194
  <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
195
195
  {/if}
@@ -34,8 +34,10 @@
34
34
  *
35
35
  * A submit outcome has no such permanent host, because the page mounts this
36
36
  * component at the moment the submit resolves. So the region goes in empty
37
- * and the message is placed inside it on the next frame, which is the same
38
- * shape of change a toast makes.
37
+ * and the message is placed inside it two frames later, which is the same
38
+ * shape of change a toast makes. One frame is not enough: Firefox builds its
39
+ * accessibility tree after the frame's callbacks run, so a region filled on
40
+ * the first frame reaches it already full and Orca hears nothing.
39
41
  *
40
42
  * The server runs no effect and has no frames, so it renders the message
41
43
  * straight away: a page delivered without its client bundle still carries
@@ -44,7 +46,9 @@
44
46
  let filled = $state(typeof window === 'undefined');
45
47
 
46
48
  $effect(() => {
47
- const frame = requestAnimationFrame(() => (filled = true));
49
+ let frame = requestAnimationFrame(() => {
50
+ frame = requestAnimationFrame(() => (filled = true));
51
+ });
48
52
  return () => cancelAnimationFrame(frame);
49
53
  });
50
54
  </script>
@@ -54,12 +58,14 @@
54
58
  interrupts whatever the reader is doing, because the submit did not go
55
59
  through and the user has to act on it. A confirmation takes aria-live
56
60
  "polite" and waits for a pause, because the work is done and nothing is being
57
- asked of anyone.
61
+ asked of anyone. It takes no role: browsers expose role="status" as a status
62
+ bar, and Orca speaks nothing inserted into one.
58
63
  -->
59
64
  <div
60
65
  class="flex items-start gap-2 text-sm {tones[tone]} {klass}"
61
- role={tone === 'danger' ? 'alert' : 'status'}
66
+ role={tone === 'danger' ? 'alert' : undefined}
62
67
  aria-live={tone === 'danger' ? 'assertive' : 'polite'}
68
+ aria-atomic={tone === 'danger' ? undefined : 'true'}
63
69
  >
64
70
  {#if filled}
65
71
  <!-- Stroked SVG, not a literal cross or tick: a font glyph lands at
@@ -58,7 +58,7 @@
58
58
  xmlns="http://www.w3.org/2000/svg"
59
59
  aria-hidden="true"
60
60
  >
61
- <g transform="translate(6 4)">
61
+ <g transform="translate(6 3)">
62
62
  <polygon class="fill-mark-ink" points="0,14 14,7 14,40 0,47" />
63
63
  <polygon class="fill-mark-foot" points="0,47 14,40 14,54 0,61" />
64
64
  <polygon class="fill-mark-step" points="14,40 28,33 28,47 14,54" />
@@ -140,9 +140,9 @@
140
140
  }
141
141
 
142
142
  /** Lands a move, or says why it cannot. True when the order changed. */
143
- function land(from: number, to: number): boolean {
143
+ function land(from: number, to: number, announce = say): boolean {
144
144
  const result = attempt(items, from, to, canMove);
145
- say(result.text);
145
+ announce(result.text);
146
146
  if (!result.moved) return false;
147
147
  const item = items[from];
148
148
  items = result.items;
@@ -157,9 +157,15 @@
157
157
  */
158
158
  async function move(from: number, to: number) {
159
159
  const focused = document.activeElement;
160
- if (!land(from, to)) return;
160
+ let text = '';
161
+ if (!land(from, to, (t) => (text = t))) return say(text);
161
162
  await tick();
162
163
  if (focused instanceof HTMLElement && document.activeElement !== focused) focused.focus();
164
+ // Firefox reports the restored focus as a new one, and Orca drops a polite
165
+ // sentence still waiting when focus arrives. The browser builds its
166
+ // accessibility tree after a frame's callbacks run, so the second frame is
167
+ // the first that reaches it after the focus.
168
+ requestAnimationFrame(() => requestAnimationFrame(() => say(text)));
163
169
  }
164
170
 
165
171
  function typing(target: EventTarget | null): boolean {
@@ -505,7 +511,10 @@
505
511
  <p id="{uid}-keys" class="sr-only">
506
512
  Press Alt with the up or down arrow to move a row, or Enter for a menu.
507
513
  </p>
508
- <p role="status" class="sr-only">
514
+ <!-- A div with no role, because Orca speaks nothing inserted into a status
515
+ role, and in Firefox drops a paragraph's text that follows the list's
516
+ own events too closely. -->
517
+ <div aria-live="polite" class="sr-only">
509
518
  {#key spoken.n}<span>{spoken.text}</span>{/key}
510
- </p>
519
+ </div>
511
520
  </div>
@@ -57,8 +57,10 @@
57
57
  /*
58
58
  * The tab for the active id is brought into view when the id changes, so a
59
59
  * page that opens on its fifth tab does not open on a strip that hides it.
60
- * `nearest` on both axes moves the strip and nothing else. A test DOM has
61
- * no layout and no scrollIntoView, and neither is an error here.
60
+ * Only the strip's own offset moves. scrollIntoView moves every scroll box
61
+ * above the tab, the page among them, so a strip below the fold pulled the
62
+ * page down to it as soon as it mounted. A test DOM lays nothing out, so
63
+ * every box is empty and nothing moves.
62
64
  */
63
65
  $effect(() => {
64
66
  const el = scroller;
@@ -67,9 +69,11 @@
67
69
  const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
68
70
  (t) => t.dataset.tab === id,
69
71
  );
70
- if (tab && typeof tab.scrollIntoView === 'function') {
71
- tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
72
- }
72
+ if (!tab) return;
73
+ const box = el.getBoundingClientRect();
74
+ const at = tab.getBoundingClientRect();
75
+ if (at.left < box.left) el.scrollLeft -= box.left - at.left;
76
+ else if (at.right > box.right) el.scrollLeft += at.right - box.right;
73
77
  });
74
78
 
75
79
  /*
@@ -17,11 +17,11 @@
17
17
  app and empty most of the time. Marking each toast `role="status"` instead
18
18
  announced nothing: assistive technology watches an existing region for
19
19
  changes, and a region that arrives already holding its text is not a
20
- change. -->
20
+ change. It carries `aria-live` and no `role="status"`: browsers expose a
21
+ status role as a status bar, and Orca speaks nothing inserted into one. -->
21
22
  <div
22
23
  data-print="hide"
23
24
  class="pointer-events-none fixed bottom-4 end-4 z-toast flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
24
- role="status"
25
25
  aria-live="polite"
26
26
  aria-atomic="false"
27
27
  >
package/dist/index.d.ts CHANGED
@@ -113,4 +113,4 @@ export { cn, type ClassValue } from './utils/cn.js';
113
113
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
114
114
  export * as motion from './motion.js';
115
115
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
116
- export declare const VERSION = "0.43.0";
116
+ export declare const VERSION = "0.44.0";
package/dist/index.js CHANGED
@@ -122,4 +122,4 @@ export * as motion from './motion.js';
122
122
  // ── Version ────────────────────────────────────────────────────────────────
123
123
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
124
124
  // this line. The build and the test suite fail when the two disagree.
125
- export const VERSION = '0.43.0';
125
+ export const VERSION = '0.44.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.43.0",
3
+ "version": "0.44.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>",