@lyeve-labs/ui-kit 0.43.0 → 0.45.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.
@@ -94,7 +94,10 @@
94
94
  </script>
95
95
 
96
96
  <details bind:this={root} class="group relative {klass}">
97
+ <!-- The name beside the avatar is hidden below sm, and the avatar is paint,
98
+ so the trigger carries its name itself. -->
97
99
  <summary
100
+ aria-label="{name}, account menu"
98
101
  class="{TOUCH_GROW} flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
99
102
  >
100
103
  <span
@@ -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;
@@ -9,6 +9,15 @@
9
9
  export interface SidebarState {
10
10
  rail: boolean;
11
11
  }
12
+
13
+ /**
14
+ * How long a pointer has to rest over the rail before it opens. This waits
15
+ * for the reader, not for an animation. A pointer crossing the 56px rail on
16
+ * its way to the page is still moving and never rests there, so the column
17
+ * does not open over the control it is heading for. One that stops on the
18
+ * rail means to read it.
19
+ */
20
+ export const RAIL_REST_MS = 100;
12
21
  </script>
13
22
 
14
23
  <script lang="ts">
@@ -161,15 +170,31 @@
161
170
  * The rail: the icon column, and the labels on demand.
162
171
  *
163
172
  * While the sidebar is the rail it is 56px wide and the nav is told so. A
164
- * pointer over it, or focus inside it, opens it to the full column over the
165
- * page rather than beside it, so the page keeps its width and the reader
166
- * still gets every label. It closes when the pointer or the focus leaves.
173
+ * pointer at rest over it, or focus inside it, opens it to the full column
174
+ * over the page rather than beside it, so the page keeps its width and the
175
+ * reader still gets every label. It closes when the pointer or the focus
176
+ * leaves. A pointer that only passes over it leaves it the rail: the open
177
+ * column covers the start of the page, and opening under a pointer bound
178
+ * for a control there held the column open over that control.
167
179
  * State rather than a hover rule, because the nav snippet has to be told
168
180
  * the labels are wanted, and a stylesheet cannot tell it.
169
181
  */
170
182
  const railMode = $derived(!isMobile && !railHidden && (rail || isNarrow));
171
183
  let railOpen = $state(false);
172
184
  const railIcons = $derived(railMode && !railOpen);
185
+ let railRest: ReturnType<typeof setTimeout> | undefined;
186
+
187
+ /** Every move starts the rest again, so only a pointer that stops opens the column. */
188
+ function onRailPointerMove() {
189
+ if (railOpen) return;
190
+ clearTimeout(railRest);
191
+ railRest = setTimeout(() => (railOpen = true), RAIL_REST_MS);
192
+ }
193
+
194
+ function onRailPointerLeave() {
195
+ clearTimeout(railRest);
196
+ railOpen = false;
197
+ }
173
198
 
174
199
  $effect(() => {
175
200
  const mobile = window.matchMedia('(max-width: 767px)');
@@ -178,12 +203,14 @@
178
203
  isMobile = mobile.matches;
179
204
  isNarrow = narrow.matches && !mobile.matches;
180
205
  if (!mobile.matches) navOpen = false;
206
+ clearTimeout(railRest);
181
207
  railOpen = false;
182
208
  };
183
209
  sync();
184
210
  mobile.addEventListener('change', sync);
185
211
  narrow.addEventListener('change', sync);
186
212
  return () => {
213
+ clearTimeout(railRest);
187
214
  mobile.removeEventListener('change', sync);
188
215
  narrow.removeEventListener('change', sync);
189
216
  };
@@ -269,8 +296,9 @@
269
296
  data-testid="app-rail"
270
297
  role="presentation"
271
298
  class="relative hidden {APP_SIDEBAR_RAIL} shrink-0 md:block"
272
- onpointerenter={() => (railOpen = true)}
273
- onpointerleave={() => (railOpen = false)}
299
+ onpointerenter={onRailPointerMove}
300
+ onpointermove={onRailPointerMove}
301
+ onpointerleave={onRailPointerLeave}
274
302
  onfocusin={() => (railOpen = true)}
275
303
  onfocusout={onRailFocusOut}
276
304
  >
@@ -8,6 +8,14 @@
8
8
  export interface SidebarState {
9
9
  rail: boolean;
10
10
  }
11
+ /**
12
+ * How long a pointer has to rest over the rail before it opens. This waits
13
+ * for the reader, not for an animation. A pointer crossing the 56px rail on
14
+ * its way to the page is still moving and never rests there, so the column
15
+ * does not open over the control it is heading for. One that stops on the
16
+ * rail means to read it.
17
+ */
18
+ export declare const RAIL_REST_MS = 100;
11
19
  /**
12
20
  * The authed application frame: the sidebar, the header bar and the content
13
21
  * column, owned once so three apps cannot each invent their own.
@@ -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" />
@@ -220,9 +220,9 @@
220
220
  return label ? `${label} ${range}` : range;
221
221
  });
222
222
 
223
- /** The target of a control, sanitized, or nothing when the step is unavailable. */
224
- function linkTo(target: number, enabled: boolean): string | undefined {
225
- return href && enabled ? safeHref(href(target)) : undefined;
223
+ /** The target of a control, sanitized, or nothing when the builder's URL is refused. */
224
+ function linkTo(target: number): string | undefined {
225
+ return href ? safeHref(href(target)) : undefined;
226
226
  }
227
227
 
228
228
  /**
@@ -250,16 +250,6 @@
250
250
  const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
251
251
 
252
252
  const btnCurrent = 'bg-brand text-ink';
253
-
254
- /**
255
- * A link with nowhere to go.
256
- *
257
- * An anchor has no `disabled`, and `disabled:` never matches one, so the two
258
- * states have to be painted rather than declared. Dropping the href is what
259
- * takes it out of the tab order and stops activation. The class is only the
260
- * part a reader can see, at the same 30% the disabled buttons use.
261
- */
262
- const linkInert = 'pointer-events-none opacity-30';
263
253
  </script>
264
254
 
265
255
  {#snippet arrow(d: string)}
@@ -281,15 +271,12 @@
281
271
 
282
272
  <!-- Previous and next, as a link when the caller builds hrefs and as a button
283
273
  when it hands over a callback. The two branches carry the same classes and
284
- the same label, so the pager reads and sounds the same either way. -->
274
+ the same label, so the pager reads and sounds the same either way. A step
275
+ with nowhere to go is a disabled button in both: an anchor without an href
276
+ is a plain element, and the accessibility tree refuses a label on one. -->
285
277
  {#snippet step(target: number, label: string, enabled: boolean, d: string)}
286
- {#if href}
287
- <a
288
- href={linkTo(target, enabled)}
289
- aria-label={label}
290
- aria-disabled={enabled ? undefined : 'true'}
291
- class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
292
- >
278
+ {#if href && enabled}
279
+ <a href={linkTo(target)} aria-label={label} class="{btnBase} {btnRest}">
293
280
  {@render arrow(d)}
294
281
  </a>
295
282
  {:else}
@@ -342,7 +329,7 @@
342
329
  </span>
343
330
  {:else if href}
344
331
  <a
345
- href={linkTo(n, true)}
332
+ href={linkTo(n)}
346
333
  aria-current={safePage === n ? 'page' : undefined}
347
334
  class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
348
335
  ? btnCurrent
@@ -60,6 +60,14 @@
60
60
  * `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
61
61
  */
62
62
  per?: BillingPeriod | 'once';
63
+ /**
64
+ * What the order charges, in cents, when the host prices more than the
65
+ * selection, such as a discount code. It stands in for the quote's total
66
+ * in the bar and the default summary, and is what `summary` and `actions`
67
+ * are handed. It counts only while `quote` describes the selection, so it
68
+ * never outlives the cart it was priced for.
69
+ */
70
+ charged?: number | null;
63
71
  /** True while a fresh price is in flight. */
64
72
  quoting?: boolean;
65
73
  /** The count the bill will carry, when the host knows better than the cart. */
@@ -124,6 +132,7 @@
124
132
  quotePeriod = null,
125
133
  period = 'monthly',
126
134
  per,
135
+ charged = null,
127
136
  quoting = false,
128
137
  billedSkus = null,
129
138
  currency = 'USD',
@@ -187,9 +196,8 @@
187
196
 
188
197
  const total = $derived(
189
198
  quoted
190
- ? period === 'annual'
191
- ? (quoted.annual_total_cents ?? null)
192
- : quoted.total_cents
199
+ ? (charged ??
200
+ (period === 'annual' ? (quoted.annual_total_cents ?? null) : quoted.total_cents))
193
201
  : null,
194
202
  );
195
203
 
@@ -26,6 +26,14 @@ interface Props {
26
26
  * `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
27
27
  */
28
28
  per?: BillingPeriod | 'once';
29
+ /**
30
+ * What the order charges, in cents, when the host prices more than the
31
+ * selection, such as a discount code. It stands in for the quote's total
32
+ * in the bar and the default summary, and is what `summary` and `actions`
33
+ * are handed. It counts only while `quote` describes the selection, so it
34
+ * never outlives the cart it was priced for.
35
+ */
36
+ charged?: number | null;
29
37
  /** True while a fresh price is in flight. */
30
38
  quoting?: boolean;
31
39
  /** The count the bill will carry, when the host knows better than the cart. */
@@ -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>
@@ -77,6 +77,7 @@
77
77
  '[&_tbody_tr:hover>td:last-child]:shadow-[inset_-1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
78
78
  '[&_tbody_tr:hover>td:first-child:last-child]:shadow-[inset_1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
79
79
  '[&_tbody_tr]:transition-colors',
80
+ '[&_tbody_tr>td]:transition-shadow',
80
81
  ].join(' ');
81
82
 
82
83
  let {
@@ -29,8 +29,8 @@
29
29
  *
30
30
  * The scrollbar is not drawn. A strip of tabs with a bar under it reads as
31
31
  * a second rule under the real one. The fades carry the same message, the
32
- * tabs are buttons so the keyboard reaches every one, and the active tab
33
- * is scrolled into view when it changes.
32
+ * arrow keys reach every tab, and the active tab is scrolled into view when
33
+ * it changes.
34
34
  */
35
35
  let scroller: HTMLElement | undefined = $state();
36
36
  let moreBefore = $state(false);
@@ -57,21 +57,86 @@
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
- $effect(() => {
65
+ function reveal(tab: HTMLElement) {
64
66
  const el = scroller;
65
- const id = active;
66
67
  if (!el) return;
67
- const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
68
- (t) => t.dataset.tab === id,
69
- );
70
- if (tab && typeof tab.scrollIntoView === 'function') {
71
- tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
72
- }
68
+ const box = el.getBoundingClientRect();
69
+ const at = tab.getBoundingClientRect();
70
+ if (at.left < box.left) el.scrollLeft -= box.left - at.left;
71
+ else if (at.right > box.right) el.scrollLeft += at.right - box.right;
72
+ }
73
+
74
+ function tabAt(index: number): HTMLElement | undefined {
75
+ return scroller?.querySelectorAll<HTMLElement>('[role="tab"]')[index];
76
+ }
77
+
78
+ const activeIndex = $derived(items.findIndex((t) => t.id === active));
79
+
80
+ $effect(() => {
81
+ const tab = activeIndex === -1 ? undefined : tabAt(activeIndex);
82
+ if (tab) reveal(tab);
73
83
  });
74
84
 
85
+ /*
86
+ * The strip is one stop in the tab order. A strip of five tabs cost five
87
+ * presses of Tab to step over, and the arrows did nothing.
88
+ *
89
+ * Activation is manual: Left, Right, Home and End move focus and the stop
90
+ * with it, and Enter, Space or a click chooses. A caller may navigate to
91
+ * another page when a tab is chosen, and a strip that chose on every arrow
92
+ * would then load a page per key press and take the reader off the strip.
93
+ *
94
+ * While focus is inside the strip the stop follows it, by key or by
95
+ * pointer. When focus leaves, the stop goes back to the selected tab. A
96
+ * selection matching no item would leave every tab at -1 and drop the
97
+ * strip out of the tab order, so the first tab holds it then.
98
+ */
99
+ let roving: number | null = $state(null);
100
+ const tabStop = $derived(roving ?? (activeIndex === -1 ? 0 : activeIndex));
101
+
102
+ function moveTo(index: number) {
103
+ const tab = tabAt(index);
104
+ if (!tab) return;
105
+ // The strip is in view when a key reaches it, and focus() would scroll
106
+ // every box above the tab, the page among them.
107
+ tab.focus({ preventScroll: true });
108
+ reveal(tab);
109
+ }
110
+
111
+ function onTabKeydown(e: KeyboardEvent, index: number) {
112
+ const last = items.length - 1;
113
+ let next: number;
114
+ switch (e.key) {
115
+ case 'ArrowRight':
116
+ next = index === last ? 0 : index + 1;
117
+ break;
118
+ case 'ArrowLeft':
119
+ next = index === 0 ? last : index - 1;
120
+ break;
121
+ case 'Home':
122
+ next = 0;
123
+ break;
124
+ case 'End':
125
+ next = last;
126
+ break;
127
+ default:
128
+ return;
129
+ }
130
+ // The arrows scroll the strip and the page, and Home and End jump the page.
131
+ e.preventDefault();
132
+ moveTo(next);
133
+ }
134
+
135
+ function onStripFocusOut(e: FocusEvent) {
136
+ if (scroller && e.relatedTarget instanceof Node && scroller.contains(e.relatedTarget)) return;
137
+ roving = null;
138
+ }
139
+
75
140
  /*
76
141
  * The focus ring is inset: the tabs sit in a box that clips to scroll, and
77
142
  * the global outline sits 2px outside its element, so on the strip's top
@@ -88,17 +153,21 @@
88
153
  <div
89
154
  bind:this={scroller}
90
155
  onscroll={measure}
156
+ onfocusout={onStripFocusOut}
91
157
  role="tablist"
92
158
  data-testid="tabs-scroll"
93
159
  class="flex gap-0.5 overflow-x-auto border-b border-line [scrollbar-width:none]"
94
160
  >
95
- {#each items as tab (tab.id)}
161
+ {#each items as tab, i (tab.id)}
96
162
  <button
97
163
  type="button"
98
164
  role="tab"
99
165
  data-tab={tab.id}
100
166
  aria-selected={active === tab.id}
167
+ tabindex={i === tabStop ? 0 : -1}
101
168
  onclick={() => onchange(tab.id)}
169
+ onfocus={() => (roving = i)}
170
+ onkeydown={(e) => onTabKeydown(e, i)}
102
171
  class="{TAB}
103
172
  {active === tab.id
104
173
  ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
@@ -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.45.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.45.0';
@@ -20,14 +20,15 @@ export interface ScrollMetrics {
20
20
  }
21
21
  export declare function scrollEdges(el: ScrollMetrics): ScrollEdges;
22
22
  /**
23
- * The fade itself: a dark gradient on the edge, outside the scroll box so it
24
- * does not scroll away with the content it describes. Dark rather than a
25
- * palette color, because the box sits on the page in one place and inside
26
- * a card in another and no surface token is the color to fade to. The
27
- * scrims use black at an alpha for the same reason and it reads in both
28
- * themes. The direction is logical, so a right-to-left page fades the other
23
+ * The fade itself: a wash of the foreground color on the edge, outside the
24
+ * scroll box so it does not scroll away with the content it describes. The
25
+ * foreground token inverts with the theme, so the fade darkens a light card
26
+ * and lightens a dark one. A black fade had nothing to darken on a dark card
27
+ * and read as no fade at all. No surface token is the color to fade to,
28
+ * because the box sits on the page in one place and inside a card in
29
+ * another. The direction is logical, so a right-to-left page fades the other
29
30
  * way.
30
31
  */
31
32
  export declare const SCROLL_EDGE = "pointer-events-none absolute inset-y-px w-6";
32
- export declare const SCROLL_EDGE_START = "start-0 bg-linear-to-r rtl:bg-linear-to-l from-black/25 to-transparent";
33
- export declare const SCROLL_EDGE_END = "end-0 bg-linear-to-l rtl:bg-linear-to-r from-black/25 to-transparent";
33
+ export declare const SCROLL_EDGE_START = "start-0 bg-linear-to-r rtl:bg-linear-to-l from-fg/25 to-transparent";
34
+ export declare const SCROLL_EDGE_END = "end-0 bg-linear-to-l rtl:bg-linear-to-r from-fg/25 to-transparent";
@@ -20,14 +20,15 @@ export function scrollEdges(el) {
20
20
  return { before: traveled > 1, after: traveled < slack - 1 };
21
21
  }
22
22
  /**
23
- * The fade itself: a dark gradient on the edge, outside the scroll box so it
24
- * does not scroll away with the content it describes. Dark rather than a
25
- * palette color, because the box sits on the page in one place and inside
26
- * a card in another and no surface token is the color to fade to. The
27
- * scrims use black at an alpha for the same reason and it reads in both
28
- * themes. The direction is logical, so a right-to-left page fades the other
23
+ * The fade itself: a wash of the foreground color on the edge, outside the
24
+ * scroll box so it does not scroll away with the content it describes. The
25
+ * foreground token inverts with the theme, so the fade darkens a light card
26
+ * and lightens a dark one. A black fade had nothing to darken on a dark card
27
+ * and read as no fade at all. No surface token is the color to fade to,
28
+ * because the box sits on the page in one place and inside a card in
29
+ * another. The direction is logical, so a right-to-left page fades the other
29
30
  * way.
30
31
  */
31
32
  export const SCROLL_EDGE = 'pointer-events-none absolute inset-y-px w-6';
32
- export const SCROLL_EDGE_START = 'start-0 bg-linear-to-r rtl:bg-linear-to-l from-black/25 to-transparent';
33
- export const SCROLL_EDGE_END = 'end-0 bg-linear-to-l rtl:bg-linear-to-r from-black/25 to-transparent';
33
+ export const SCROLL_EDGE_START = 'start-0 bg-linear-to-r rtl:bg-linear-to-l from-fg/25 to-transparent';
34
+ export const SCROLL_EDGE_END = 'end-0 bg-linear-to-l rtl:bg-linear-to-r from-fg/25 to-transparent';
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.45.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>",