@lyeve-labs/ui-kit 0.26.0 → 0.28.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.
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { HIT_AREA } from '../internal/touch.js';
4
+ import * as motion from '../motion.js';
4
5
  import {
5
6
  TONE_GLYPH,
6
7
  statusTone,
@@ -8,10 +9,30 @@
8
9
  type StatusToneInput,
9
10
  } from '../internal/tone.js';
10
11
 
12
+ /**
13
+ * How long a confirmation holds before it clears itself.
14
+ *
15
+ * A toast waits 4000ms. This waits longer because it carries more: a toast is
16
+ * one line floating over the page, and an alert is a title and a sentence
17
+ * sitting in the reading order, which takes a beat to find and a beat to
18
+ * read.
19
+ */
20
+ const DISMISS_AFTER = 5000;
21
+
11
22
  interface Props {
12
23
  tone?: StatusToneInput;
13
24
  title?: string;
14
25
  dismissible?: boolean;
26
+ /**
27
+ * Milliseconds before the alert closes itself, or `true` for the default.
28
+ *
29
+ * A confirmation clears itself. A warning and a failure stay until
30
+ * dismissed, so this is off by default and a danger alert never takes it.
31
+ * Pass it wherever the alert reports the outcome of a submit; leave it off
32
+ * where a success tone states a standing condition, which does not stop
33
+ * being true after five seconds.
34
+ */
35
+ autoDismiss?: number | boolean;
15
36
  ondismiss?: () => void;
16
37
  class?: string;
17
38
  children?: Snippet;
@@ -21,6 +42,7 @@
21
42
  tone = 'brand',
22
43
  title = undefined,
23
44
  dismissible = false,
45
+ autoDismiss = false,
24
46
  ondismiss = undefined,
25
47
  class: klass = '',
26
48
  children,
@@ -35,54 +57,100 @@
35
57
  };
36
58
 
37
59
  const t = $derived(statusTone(tone));
60
+
61
+ /**
62
+ * A failure and a warning interrupt, because the reader has to act. Anything
63
+ * else waits for a pause. FormMessage already splits them this way; Alert
64
+ * announced every tone assertively, so a green confirmation cut across
65
+ * whatever was being read. An alert that also clears itself must not be
66
+ * assertive: it would interrupt to say something and then take it away.
67
+ */
68
+ const live = $derived(t === 'danger' || t === 'warn');
69
+
70
+ const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
71
+
72
+ /** An alert that can close itself can also be closed by hand. */
73
+ const closable = $derived(dismissible || delay > 0);
74
+
75
+ let closed = $state(false);
76
+ /** The pointer is over the alert, or focus is inside it. */
77
+ let held = $state(false);
78
+
79
+ function close(): void {
80
+ closed = true;
81
+ ondismiss?.();
82
+ }
83
+
84
+ /**
85
+ * The alert owns whether it is on screen, rather than asking the page to
86
+ * un-render it. An outcome alert lives inside `{#if form}` in the page that
87
+ * submitted, and nothing clears a form result short of a navigation, so a
88
+ * callback has nothing to act on.
89
+ */
90
+ $effect(() => {
91
+ if (closed || held || delay <= 0) return;
92
+ const timer = setTimeout(close, delay);
93
+ return () => clearTimeout(timer);
94
+ });
38
95
  </script>
39
96
 
40
- <div
41
- class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[t].wrap} {klass}"
42
- role="alert"
43
- >
44
- <span
45
- class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current {tones[
46
- t
47
- ].icon}"
48
- aria-hidden="true"
97
+ {#if !closed}
98
+ <!-- The timer stops while the pointer is over the alert or focus is inside
99
+ it, and starts again when they leave. Text is never pulled away from
100
+ somebody reading it. -->
101
+ <div
102
+ transition:motion.toast|global
103
+ 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'}
106
+ onmouseenter={closable ? () => (held = true) : undefined}
107
+ onmouseleave={closable ? () => (held = false) : undefined}
108
+ onfocusin={closable ? () => (held = true) : undefined}
109
+ onfocusout={closable ? () => (held = false) : undefined}
49
110
  >
50
- <svg
51
- width="11"
52
- height="11"
53
- viewBox="0 0 24 24"
54
- fill="none"
55
- stroke="currentColor"
56
- stroke-width="2.5"
57
- stroke-linecap="round"
58
- stroke-linejoin="round"
59
- >
60
- <path d={TONE_GLYPH[t]} />
61
- </svg>
62
- </span>
63
- <div class="flex-1 min-w-0">
64
- {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
65
- {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
66
- {@render children()}
67
- </div>{/if}
68
- </div>
69
- {#if dismissible}
70
- <button
71
- type="button"
72
- onclick={ondismiss}
73
- class="{HIT_AREA} shrink-0 text-faint transition-colors hover:text-fg"
74
- aria-label="Dismiss"
111
+ <span
112
+ class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current {tones[
113
+ t
114
+ ].icon}"
115
+ aria-hidden="true"
75
116
  >
76
117
  <svg
77
- width="16"
78
- height="16"
118
+ width="11"
119
+ height="11"
79
120
  viewBox="0 0 24 24"
80
121
  fill="none"
81
122
  stroke="currentColor"
82
- stroke-width="2"
123
+ stroke-width="2.5"
83
124
  stroke-linecap="round"
84
- aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg
125
+ stroke-linejoin="round"
126
+ >
127
+ <path d={TONE_GLYPH[t]} />
128
+ </svg>
129
+ </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>
136
+ {#if closable}
137
+ <button
138
+ type="button"
139
+ onclick={close}
140
+ class="{HIT_AREA} shrink-0 rounded text-faint transition-colors hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
141
+ aria-label="Dismiss"
85
142
  >
86
- </button>
87
- {/if}
88
- </div>
143
+ <svg
144
+ width="16"
145
+ height="16"
146
+ viewBox="0 0 24 24"
147
+ fill="none"
148
+ stroke="currentColor"
149
+ stroke-width="2"
150
+ stroke-linecap="round"
151
+ aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg
152
+ >
153
+ </button>
154
+ {/if}
155
+ </div>
156
+ {/if}
@@ -4,6 +4,16 @@ interface Props {
4
4
  tone?: StatusToneInput;
5
5
  title?: string;
6
6
  dismissible?: boolean;
7
+ /**
8
+ * Milliseconds before the alert closes itself, or `true` for the default.
9
+ *
10
+ * A confirmation clears itself. A warning and a failure stay until
11
+ * dismissed, so this is off by default and a danger alert never takes it.
12
+ * Pass it wherever the alert reports the outcome of a submit; leave it off
13
+ * where a success tone states a standing condition, which does not stop
14
+ * being true after five seconds.
15
+ */
16
+ autoDismiss?: number | boolean;
7
17
  ondismiss?: () => void;
8
18
  class?: string;
9
19
  children?: Snippet;
@@ -256,7 +256,11 @@
256
256
  onfocusin={() => (railOpen = true)}
257
257
  onfocusout={onRailFocusOut}
258
258
  >
259
- <div class="absolute inset-y-0 start-0 z-dropdown flex {railOpen ? 'shadow-2xl' : ''}">
259
+ <div
260
+ class="absolute inset-y-0 start-0 z-dropdown flex transition-shadow {railOpen
261
+ ? 'shadow-2xl'
262
+ : ''}"
263
+ >
260
264
  {@render sidebar(isMobile, railIcons)}
261
265
  </div>
262
266
  </div>
@@ -42,12 +42,19 @@
42
42
  Under a Table's default `overflow-wrap: anywhere` a status rendered as
43
43
  `dra ft` and a role as `sup er_a dmi n`. The label sits in a truncating
44
44
  span so a badge whose caller caps its width ends in an ellipsis instead of
45
- painting past its own border; with no cap the span never shrinks. -->
45
+ painting past its own border; with no cap the span never shrinks.
46
+
47
+ The svg rules are what let a caller put an icon in the label. Preflight
48
+ makes every svg a block, and a block inside the label takes a line of its
49
+ own, so an icon and its text stacked and the badge rendered two rows tall
50
+ inside a pill. A flex row here would fix the stacking and lose the
51
+ ellipsis, which is the thing the span exists for, so the icon goes back to
52
+ being inline and sits on the text's optical centre. -->
46
53
  <span
47
54
  class="inline-flex max-w-full items-center font-medium whitespace-nowrap rounded-full border {tones[
48
55
  tone
49
56
  ]} {sizes[size]} {klass}"
50
57
  >
51
58
  {#if dot}<span class="w-1.5 h-1.5 shrink-0 rounded-full {dotColor[tone]}"></span>{/if}
52
- <span class="min-w-0 truncate">{@render children()}</span>
59
+ <span class="min-w-0 truncate [&>svg]:inline [&>svg]:align-middle">{@render children()}</span>
53
60
  </span>
@@ -3,9 +3,25 @@
3
3
  import { HIT_AREA } from '../internal/touch.js';
4
4
  import type { Snippet } from 'svelte';
5
5
 
6
+ /**
7
+ * How long a confirmation holds before it clears itself. The same five
8
+ * seconds Alert waits, because a reader crossing between the two surfaces
9
+ * should not have to learn two timings.
10
+ */
11
+ const DISMISS_AFTER = 5000;
12
+
6
13
  interface Props {
7
14
  tone?: StatusToneInput;
8
15
  dismissible?: boolean;
16
+ /**
17
+ * Milliseconds before the banner closes itself, or `true` for the default.
18
+ *
19
+ * A confirmation clears itself. A warning and a failure stay until
20
+ * dismissed, so this is off by default. Pass it wherever the banner
21
+ * reports the outcome of a submit; leave it off where it states a standing
22
+ * condition, which does not stop being true after five seconds.
23
+ */
24
+ autoDismiss?: number | boolean;
9
25
  ondismiss?: () => void;
10
26
  class?: string;
11
27
  children: Snippet;
@@ -15,6 +31,7 @@
15
31
  let {
16
32
  tone = 'brand',
17
33
  dismissible = false,
34
+ autoDismiss = false,
18
35
  ondismiss = undefined,
19
36
  class: cls = '',
20
37
  children,
@@ -22,12 +39,25 @@
22
39
  }: Props = $props();
23
40
 
24
41
  let visible = $state(true);
42
+ /** The pointer is over the banner, or focus is inside it. */
43
+ let held = $state(false);
25
44
 
26
45
  function dismiss() {
27
46
  visible = false;
28
47
  ondismiss?.();
29
48
  }
30
49
 
50
+ const delay = $derived(autoDismiss === true ? DISMISS_AFTER : Number(autoDismiss) || 0);
51
+
52
+ /** A banner that can close itself can also be closed by hand. */
53
+ const closable = $derived(dismissible || delay > 0);
54
+
55
+ $effect(() => {
56
+ if (!visible || held || delay <= 0) return;
57
+ const timer = setTimeout(dismiss, delay);
58
+ return () => clearTimeout(timer);
59
+ });
60
+
31
61
  const tones: Record<StatusTone, string> = {
32
62
  neutral: 'bg-surface-2 border-line',
33
63
  brand: 'bg-brand/10 border-brand/20',
@@ -40,9 +70,16 @@
40
70
  </script>
41
71
 
42
72
  {#if visible}
73
+ <!-- The timer stops while the pointer is over the banner or focus is inside
74
+ it, and starts again when they leave. Text is never pulled away from
75
+ somebody reading it. -->
43
76
  <div
44
77
  role="status"
45
78
  class="relative flex items-center gap-3 border-b px-4 py-3 text-sm {tones[t]} {cls}"
79
+ onmouseenter={closable ? () => (held = true) : undefined}
80
+ onmouseleave={closable ? () => (held = false) : undefined}
81
+ onfocusin={closable ? () => (held = true) : undefined}
82
+ onfocusout={closable ? () => (held = false) : undefined}
46
83
  >
47
84
  <div class="flex flex-1 items-center justify-center gap-3 text-fg">
48
85
  {@render children()}
@@ -52,7 +89,7 @@
52
89
  <div class="shrink-0">{@render action()}</div>
53
90
  {/if}
54
91
 
55
- {#if dismissible}
92
+ {#if closable}
56
93
  <button
57
94
  type="button"
58
95
  onclick={dismiss}
@@ -3,6 +3,15 @@ import type { Snippet } from 'svelte';
3
3
  interface Props {
4
4
  tone?: StatusToneInput;
5
5
  dismissible?: boolean;
6
+ /**
7
+ * Milliseconds before the banner closes itself, or `true` for the default.
8
+ *
9
+ * A confirmation clears itself. A warning and a failure stay until
10
+ * dismissed, so this is off by default. Pass it wherever the banner
11
+ * reports the outcome of a submit; leave it off where it states a standing
12
+ * condition, which does not stop being true after five seconds.
13
+ */
14
+ autoDismiss?: number | boolean;
6
15
  ondismiss?: () => void;
7
16
  class?: string;
8
17
  children: Snippet;
@@ -67,7 +67,11 @@
67
67
  }
68
68
  </script>
69
69
 
70
+ <!-- The marker is on the label because the label is the whole control: the
71
+ switch and its text are one field, and an overlay sizing itself to a form
72
+ counts what it can see. -->
70
73
  <label
74
+ data-field
71
75
  class="inline-flex items-start gap-2.5 cursor-pointer select-none {disabled
72
76
  ? 'opacity-50 cursor-not-allowed'
73
77
  : ''} {cls}"
package/dist/index.d.ts CHANGED
@@ -93,4 +93,4 @@ export { cn, type ClassValue } from './utils/cn.js';
93
93
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
94
94
  export * as motion from './motion.js';
95
95
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
96
- export declare const VERSION = "0.26.0";
96
+ export declare const VERSION = "0.28.0";
package/dist/index.js CHANGED
@@ -103,4 +103,4 @@ export * as motion from './motion.js';
103
103
  // ── Version ────────────────────────────────────────────────────────────────
104
104
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
105
105
  // this line; the build and the test suite fail when the two disagree.
106
- export const VERSION = '0.26.0';
106
+ export const VERSION = '0.28.0';
@@ -173,13 +173,19 @@ export declare function sectionHeading(level: 2 | 3, variant?: SectionVariant):
173
173
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
174
174
  * locale flips, and a physical border leaves the rule on the wrong edge.
175
175
  */
176
- export declare const APP_SIDEBAR = "h-full shrink-0 flex-col border-e border-line bg-surface";
176
+ export declare const APP_SIDEBAR = "h-full shrink-0 flex-col border-e border-line bg-surface transition-[width] duration-base ease-move";
177
177
  /**
178
178
  * The sidebar's two widths. Expanded is the 224px column every authed screen
179
179
  * had. The rail is the 56px icon column the theme had named and nothing used:
180
180
  * at 768px the expanded column left 496px for the page, and a flow editor with
181
181
  * two docked panes had no canvas at all. The shell picks between them; the
182
182
  * width is not part of APP_SIDEBAR so the aside cannot carry both.
183
+ *
184
+ * The travel between them is on APP_SIDEBAR, which is the one class both
185
+ * widths share. It is a disclosure opening sideways, which is the same thing
186
+ * an accordion does downwards, so it takes the accordion's rung and curve:
187
+ * base and move. It was the most frequent state change in the product and the
188
+ * only one that happened in a single frame.
183
189
  */
184
190
  export declare const APP_SIDEBAR_WIDE = "w-sidebar";
185
191
  export declare const APP_SIDEBAR_RAIL = "w-nav-rail";
@@ -207,13 +207,19 @@ export function sectionHeading(level, variant = 'default') {
207
207
  * `border-e` and not `border-r`: the shell is the one place a right-to-left
208
208
  * locale flips, and a physical border leaves the rule on the wrong edge.
209
209
  */
210
- export const APP_SIDEBAR = 'h-full shrink-0 flex-col border-e border-line bg-surface';
210
+ export const APP_SIDEBAR = 'h-full shrink-0 flex-col border-e border-line bg-surface transition-[width] duration-base ease-move';
211
211
  /**
212
212
  * The sidebar's two widths. Expanded is the 224px column every authed screen
213
213
  * had. The rail is the 56px icon column the theme had named and nothing used:
214
214
  * at 768px the expanded column left 496px for the page, and a flow editor with
215
215
  * two docked panes had no canvas at all. The shell picks between them; the
216
216
  * width is not part of APP_SIDEBAR so the aside cannot carry both.
217
+ *
218
+ * The travel between them is on APP_SIDEBAR, which is the one class both
219
+ * widths share. It is a disclosure opening sideways, which is the same thing
220
+ * an accordion does downwards, so it takes the accordion's rung and curve:
221
+ * base and move. It was the most frequent state change in the product and the
222
+ * only one that happened in a single frame.
217
223
  */
218
224
  export const APP_SIDEBAR_WIDE = 'w-sidebar';
219
225
  export const APP_SIDEBAR_RAIL = 'w-nav-rail';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.26.0",
3
+ "version": "0.28.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>",