@vitavision/ui 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitavision/ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "The vitavision instrument design system: Tailwind v4 tokens, theme, and React primitives.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,148 @@
1
+ /*
2
+ * Where `toast()` shows up.
3
+ *
4
+ * Toasts are for news that does not need an answer — a file loaded, a bake finished, a
5
+ * worker failed in the background. Anything that must be acknowledged is a `Dialog`, and
6
+ * anything tied to one control is that control's `Field` error. So a toast never carries an
7
+ * action beyond dismissing it, it goes away by itself, and it holds still while someone is
8
+ * reading it (pointer over the stack, or focus inside it).
9
+ *
10
+ * The tones reuse the `Callout` vocabulary — the accent for a fact, the verdict colours for
11
+ * success, caveat and failure — on an opaque `overlay` surface, since a toast floats over
12
+ * whatever the viewport is drawing.
13
+ */
14
+
15
+ import { CircleCheck, CircleX, Info, TriangleAlert, X } from "lucide-react";
16
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
17
+ import type { FocusEvent } from "react";
18
+
19
+ import { cn, focusRing } from "./cn";
20
+ import { defaultToastStore, type ToastRecord, type ToastStore, type ToastTone } from "./toastStore";
21
+
22
+ const TONE: Record<ToastTone, { border: string; icon: string; Icon: typeof Info }> = {
23
+ info: { border: "border-signal/30", icon: "text-signal", Icon: Info },
24
+ success: { border: "border-normal/30", icon: "text-normal", Icon: CircleCheck },
25
+ warn: { border: "border-warn/30", icon: "text-warn", Icon: TriangleAlert },
26
+ error: { border: "border-defect/30", icon: "text-defect", Icon: CircleX },
27
+ };
28
+
29
+ const NO_TOASTS: readonly ToastRecord[] = [];
30
+
31
+ /** Props of `Toaster`. */
32
+ export interface ToasterProps {
33
+ /** The store to show. Defaults to the one behind `toast()`. */
34
+ store?: ToastStore | undefined;
35
+ /** How many toasts are shown at once; older ones wait. Defaults to 5. */
36
+ limit?: number | undefined;
37
+ /** Merged with the stack's own classes through `cn` (it is fixed to the bottom-right corner). */
38
+ className?: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * The stack of notifications raised with `toast()`. Mount it once, near the root.
43
+ *
44
+ * Fixed to the bottom-right corner, newest at the bottom. Each toast dismisses itself after
45
+ * its `duration`; the countdown pauses while the pointer is over the stack or focus is inside
46
+ * it. A `section` landmark ("Notifications") holding a polite live region, so new toasts are
47
+ * announced; an `error` toast is a `role="alert"`, announced at once. Each toast carries
48
+ * `data-tone` and a "Dismiss" button.
49
+ */
50
+ export function Toaster({ store = defaultToastStore, limit = 5, className }: ToasterProps) {
51
+ const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot, () => NO_TOASTS);
52
+ const [hovered, setHovered] = useState(false);
53
+ const [focused, setFocused] = useState(false);
54
+ const shown = toasts.slice(-Math.max(1, limit));
55
+
56
+ const onBlur = (event: FocusEvent<HTMLElement>) => {
57
+ if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
58
+ };
59
+
60
+ return (
61
+ <section
62
+ aria-label="Notifications"
63
+ onPointerEnter={() => setHovered(true)}
64
+ onPointerLeave={() => setHovered(false)}
65
+ onFocus={() => setFocused(true)}
66
+ onBlur={onBlur}
67
+ className={cn(
68
+ "pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]",
69
+ className,
70
+ )}
71
+ >
72
+ <ol aria-live="polite" className="flex flex-col gap-2">
73
+ {shown.map((record) => (
74
+ <ToastItem
75
+ key={record.id}
76
+ record={record}
77
+ paused={hovered || focused}
78
+ onDismiss={() => store.dismiss(record.id)}
79
+ />
80
+ ))}
81
+ </ol>
82
+ </section>
83
+ );
84
+ }
85
+
86
+ function ToastItem({
87
+ record,
88
+ paused,
89
+ onDismiss,
90
+ }: {
91
+ record: ToastRecord;
92
+ paused: boolean;
93
+ onDismiss: () => void;
94
+ }) {
95
+ const { border, icon, Icon } = TONE[record.tone];
96
+ // What is left of the countdown, carried across pauses; restarted when the toast is
97
+ // replaced in place (a new record under the same id).
98
+ const remainingRef = useRef({ record, ms: record.duration });
99
+ const dismissRef = useRef(onDismiss);
100
+ useEffect(() => {
101
+ dismissRef.current = onDismiss;
102
+ });
103
+
104
+ useEffect(() => {
105
+ if (remainingRef.current.record !== record) remainingRef.current = { record, ms: record.duration };
106
+ if (paused || !Number.isFinite(remainingRef.current.ms)) return;
107
+ const started = Date.now();
108
+ const timer = setTimeout(() => dismissRef.current(), remainingRef.current.ms);
109
+ return () => {
110
+ clearTimeout(timer);
111
+ remainingRef.current.ms = Math.max(0, remainingRef.current.ms - (Date.now() - started));
112
+ };
113
+ }, [paused, record]);
114
+
115
+ return (
116
+ // The `alert` role sits on the toast inside the list item, not on the `<li>`: an item with
117
+ // another role is no longer a list item, and the list would be malformed.
118
+ <li className="pointer-events-auto">
119
+ <div
120
+ role={record.tone === "error" ? "alert" : undefined}
121
+ data-tone={record.tone}
122
+ className={cn(
123
+ "flex items-start gap-2.5 rounded-panel border bg-overlay px-3 py-2.5 text-sm leading-snug shadow-lg shadow-black/25",
124
+ "transition-[opacity,translate] duration-200 starting:translate-y-2 starting:opacity-0",
125
+ border,
126
+ )}
127
+ >
128
+ <Icon className={cn("mt-0.5 size-4 shrink-0", icon)} aria-hidden />
129
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
130
+ <p className="font-medium text-fg">{record.title}</p>
131
+ {record.description && <p className="text-fg-muted">{record.description}</p>}
132
+ </div>
133
+ <button
134
+ type="button"
135
+ aria-label="Dismiss"
136
+ title="Dismiss"
137
+ onClick={onDismiss}
138
+ className={cn(
139
+ "-mr-1 grid size-6 shrink-0 place-items-center rounded-control text-fg-subtle transition-colors hover:bg-raised hover:text-fg",
140
+ focusRing,
141
+ )}
142
+ >
143
+ <X className="size-3.5" aria-hidden />
144
+ </button>
145
+ </div>
146
+ </li>
147
+ );
148
+ }
@@ -53,5 +53,15 @@ export { Slider } from "./Slider";
53
53
  export { Table, type Column } from "./Table";
54
54
  export { Checkbox, Switch } from "./Toggle";
55
55
  export { ToggleChip } from "./ToggleChip";
56
+ export { Toaster, type ToasterProps } from "./Toaster";
57
+ export {
58
+ createToastStore,
59
+ defaultToastStore,
60
+ toast,
61
+ type ToastOptions,
62
+ type ToastRecord,
63
+ type ToastStore,
64
+ type ToastTone,
65
+ } from "./toastStore";
56
66
  export { InfoHint, Tooltip, TooltipProvider } from "./Tooltip";
57
67
  export { VectorInput, type VectorInputProps } from "./VectorInput";
@@ -0,0 +1,128 @@
1
+ /*
2
+ * Notifications, as a store any module can write to.
3
+ *
4
+ * A toast is raised from wherever the news is — a file loader, a worker's error handler, a
5
+ * bake that finished — which is rarely a component with a context in reach. So `toast()` is
6
+ * a plain function over a module-level store, and `<Toaster />`, mounted once, is its only
7
+ * reader. The store holds no timers and touches no DOM: auto-dismissal belongs to the
8
+ * `Toaster`, which is what knows whether a reader is hovering it.
9
+ */
10
+
11
+ /** A toast's kind: a fact (`info`), a completed action (`success`), a caveat (`warn`), a failure (`error`). */
12
+ export type ToastTone = "info" | "success" | "warn" | "error";
13
+
14
+ /** What `toast()` takes. */
15
+ export interface ToastOptions {
16
+ /** One line: what happened. */
17
+ title: string;
18
+ /** More detail, and what to do about it. */
19
+ description?: string | undefined;
20
+ /** Defaults to `info`. */
21
+ tone?: ToastTone | undefined;
22
+ /**
23
+ * How long it stays, in milliseconds, while not hovered or focused. Defaults to 5000
24
+ * (8000 for `error`); `Infinity` keeps it until dismissed.
25
+ */
26
+ duration?: number | undefined;
27
+ /** Replace the toast with this id instead of adding one — for progress that updates in place. */
28
+ id?: string | undefined;
29
+ }
30
+
31
+ /** A toast in the store. */
32
+ export interface ToastRecord {
33
+ /** Identifies the toast to `dismiss` and to `id` replacement. */
34
+ id: string;
35
+ /** One line: what happened. */
36
+ title: string;
37
+ /** More detail, if any. */
38
+ description: string | undefined;
39
+ /** Its kind. */
40
+ tone: ToastTone;
41
+ /** Milliseconds before it dismisses itself (`Infinity`: never). */
42
+ duration: number;
43
+ }
44
+
45
+ /**
46
+ * A toast store: what `toast()` writes to and `<Toaster />` reads. Every member may be called
47
+ * detached.
48
+ */
49
+ export interface ToastStore {
50
+ /** Add a toast (or replace the one with `options.id`), returning its id. */
51
+ readonly toast: (options: ToastOptions) => string;
52
+ /** Remove one toast, or every toast when `id` is omitted. */
53
+ readonly dismiss: (id?: string) => void;
54
+ /** The toasts, oldest first. The same array until the store changes. */
55
+ readonly getSnapshot: () => readonly ToastRecord[];
56
+ /** Be told when the toasts change; returns the unsubscribe function. */
57
+ readonly subscribe: (listener: () => void) => () => void;
58
+ }
59
+
60
+ const DEFAULT_DURATION = 5000;
61
+ const ERROR_DURATION = 8000;
62
+
63
+ /**
64
+ * A new, empty toast store. The package keeps one (behind `toast()` and the default
65
+ * `<Toaster />`); create another only to isolate a region or a test.
66
+ *
67
+ * @returns The store.
68
+ */
69
+ export function createToastStore(): ToastStore {
70
+ let toasts: readonly ToastRecord[] = [];
71
+ let sequence = 0;
72
+ const listeners = new Set<() => void>();
73
+ const commit = (next: readonly ToastRecord[]) => {
74
+ toasts = next;
75
+ for (const listener of [...listeners]) listener();
76
+ };
77
+
78
+ return {
79
+ toast: (options) => {
80
+ const tone = options.tone ?? "info";
81
+ sequence += 1;
82
+ const record: ToastRecord = {
83
+ id: options.id ?? `toast-${sequence}`,
84
+ title: options.title,
85
+ description: options.description,
86
+ tone,
87
+ duration: options.duration ?? (tone === "error" ? ERROR_DURATION : DEFAULT_DURATION),
88
+ };
89
+ const existing = toasts.findIndex((t) => t.id === record.id);
90
+ commit(existing === -1 ? [...toasts, record] : toasts.map((t, i) => (i === existing ? record : t)));
91
+ return record.id;
92
+ },
93
+ dismiss: (id) => {
94
+ if (id === undefined) {
95
+ if (toasts.length > 0) commit([]);
96
+ return;
97
+ }
98
+ if (toasts.some((t) => t.id === id)) commit(toasts.filter((t) => t.id !== id));
99
+ },
100
+ getSnapshot: () => toasts,
101
+ subscribe: (listener) => {
102
+ listeners.add(listener);
103
+ return () => {
104
+ listeners.delete(listener);
105
+ };
106
+ },
107
+ };
108
+ }
109
+
110
+ /** The store behind `toast()` and a `<Toaster />` given no `store`. */
111
+ export const defaultToastStore: ToastStore = createToastStore();
112
+
113
+ /**
114
+ * Raise a notification, from anywhere — shown by the mounted `<Toaster />`.
115
+ *
116
+ * `toast({ title: "Scenario loaded", tone: "success" })`. Pass `id` to update a toast in
117
+ * place (a progress message that becomes a result). `toast.dismiss(id)` removes one early,
118
+ * `toast.dismiss()` all of them.
119
+ *
120
+ * @param options - The message, its tone and its duration.
121
+ * @returns The toast's id.
122
+ */
123
+ export const toast: ((options: ToastOptions) => string) & {
124
+ /** Remove one toast, or every toast when `id` is omitted. */
125
+ dismiss: (id?: string) => void;
126
+ } = Object.assign((options: ToastOptions) => defaultToastStore.toast(options), {
127
+ dismiss: (id?: string) => defaultToastStore.dismiss(id),
128
+ });
package/src/styles.css CHANGED
@@ -159,13 +159,30 @@
159
159
  list-style: none;
160
160
  }
161
161
 
162
+ /*
163
+ * Reduced motion makes motion synchronous, not merely short.
164
+ *
165
+ * A transition of any non-zero duration -- 0.01ms included -- is still a transition: the
166
+ * computed style keeps its old value until the next frame, so code that sets a style and
167
+ * measures in the same tick (mermaid's layout) reads stale numbers wherever an element
168
+ * has `transition-property: all`. A 0s transition is never started, so the new value is
169
+ * visible at once. Nothing in this repo waits for `transitionend`.
170
+ *
171
+ * Animations keep a name with a 0s duration rather than `animation: none`: the animation
172
+ * still runs to its end state and fires `animationend` immediately, which is what Radix
173
+ * `Presence` waits for before unmounting an overlay whose consumer gave it an exit
174
+ * animation (`data-[state=closed]:animate-out`). `animation: none` would unmount it at
175
+ * once too, but would also freeze `animate-spin` / `animate-pulse` in their first frame.
176
+ */
162
177
  @media (prefers-reduced-motion: reduce) {
163
178
  *,
164
179
  *::before,
165
180
  *::after {
166
- animation-duration: 0.01ms !important;
181
+ animation-duration: 0s !important;
182
+ animation-delay: 0s !important;
167
183
  animation-iteration-count: 1 !important;
168
- transition-duration: 0.01ms !important;
184
+ transition-duration: 0s !important;
185
+ transition-delay: 0s !important;
169
186
  }
170
187
  }
171
188
  }