@lyeve-labs/ui-kit 0.44.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
@@ -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.
@@ -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. */
@@ -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);
@@ -62,20 +62,81 @@
62
62
  * page down to it as soon as it mounted. A test DOM lays nothing out, so
63
63
  * every box is empty and nothing moves.
64
64
  */
65
- $effect(() => {
65
+ function reveal(tab: HTMLElement) {
66
66
  const el = scroller;
67
- const id = active;
68
67
  if (!el) return;
69
- const tab = [...el.querySelectorAll<HTMLElement>('[role="tab"]')].find(
70
- (t) => t.dataset.tab === id,
71
- );
72
- if (!tab) return;
73
68
  const box = el.getBoundingClientRect();
74
69
  const at = tab.getBoundingClientRect();
75
70
  if (at.left < box.left) el.scrollLeft -= box.left - at.left;
76
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);
77
83
  });
78
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
+
79
140
  /*
80
141
  * The focus ring is inset: the tabs sit in a box that clips to scroll, and
81
142
  * the global outline sits 2px outside its element, so on the strip's top
@@ -92,17 +153,21 @@
92
153
  <div
93
154
  bind:this={scroller}
94
155
  onscroll={measure}
156
+ onfocusout={onStripFocusOut}
95
157
  role="tablist"
96
158
  data-testid="tabs-scroll"
97
159
  class="flex gap-0.5 overflow-x-auto border-b border-line [scrollbar-width:none]"
98
160
  >
99
- {#each items as tab (tab.id)}
161
+ {#each items as tab, i (tab.id)}
100
162
  <button
101
163
  type="button"
102
164
  role="tab"
103
165
  data-tab={tab.id}
104
166
  aria-selected={active === tab.id}
167
+ tabindex={i === tabStop ? 0 : -1}
105
168
  onclick={() => onchange(tab.id)}
169
+ onfocus={() => (roving = i)}
170
+ onkeydown={(e) => onTabKeydown(e, i)}
106
171
  class="{TAB}
107
172
  {active === tab.id
108
173
  ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
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.44.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.44.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.44.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>",