@vitavision/ui 0.8.0 → 0.10.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.
@@ -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
+ }
@@ -52,8 +52,8 @@ export interface VectorInputProps {
52
52
  * A row of number fields editing one small vector, with the axis labels before the fields
53
53
  * and the unit once after them.
54
54
  *
55
- * Controlled. A `role="group"` named by `aria-label`, each field named by its label and
56
- * described by the unit. While a field has focus it shows what is typed; on blur or Enter it
55
+ * Controlled. A `role="group"` named by `aria-label`. Each field is named by the group and its
56
+ * label ("Translation x"), so two vectors on one screen stay apart, and is described by the unit. While a field has focus it shows what is typed; on blur or Enter it
57
57
  * shows the value at `precision`; Escape restores the value it had on focus. With `readOnly`
58
58
  * it renders a `ReadoutStrip` (`x 0.100 m · y …`) instead. The row carries `data-readonly`
59
59
  * and `data-disabled`.
@@ -106,6 +106,7 @@ export function VectorInput({
106
106
  <label key={index} className="flex min-w-0 flex-1 items-center gap-1">
107
107
  <span className="shrink-0 font-mono text-[11px] text-fg-subtle">{labelOf(index)}</span>
108
108
  <ComponentField
109
+ name={`${ariaLabel} ${labelOf(index)}`}
109
110
  value={component}
110
111
  precision={precision}
111
112
  step={step}
@@ -128,6 +129,7 @@ export function VectorInput({
128
129
 
129
130
  /** One component: formatted at rest, the typed text while focused. */
130
131
  function ComponentField({
132
+ name,
131
133
  value,
132
134
  precision,
133
135
  step,
@@ -137,6 +139,7 @@ function ComponentField({
137
139
  describedBy,
138
140
  onCommit,
139
141
  }: {
142
+ name: string;
140
143
  value: number;
141
144
  precision: number;
142
145
  step: number | undefined;
@@ -167,6 +170,7 @@ function ComponentField({
167
170
  min={min}
168
171
  max={max}
169
172
  disabled={disabled}
173
+ aria-label={name}
170
174
  aria-describedby={describedBy}
171
175
  onFocus={() => setDraft({ text: formatted, initial: value })}
172
176
  onBlur={() => setDraft(null)}
@@ -12,6 +12,16 @@ export { Button, ButtonLink, buttonClasses, type ButtonSize, type ButtonVariant
12
12
  export { DensityProvider, byDensity, useDensity, type Density } from "./Density";
13
13
  export { ConfirmDialog, Dialog, DialogClose } from "./Dialog";
14
14
  export { Disclosure } from "./Disclosure";
15
+ export {
16
+ DropdownMenu,
17
+ MenuCheckboxItem,
18
+ MenuItem,
19
+ MenuLabel,
20
+ MenuSeparator,
21
+ type DropdownMenuProps,
22
+ type MenuCheckboxItemProps,
23
+ type MenuItemProps,
24
+ } from "./DropdownMenu";
15
25
  export {
16
26
  Callout,
17
27
  type CalloutTone,
@@ -31,6 +41,14 @@ export {
31
41
  useControlHeight,
32
42
  type NumberInputProps,
33
43
  } from "./Input";
44
+ export { Kbd } from "./Kbd";
45
+ export {
46
+ Listbox,
47
+ type ListboxOption,
48
+ type ListboxOptionState,
49
+ type ListboxProps,
50
+ } from "./Listbox";
51
+ export { formatNumber, parseNumber } from "./numberText";
34
52
  export {
35
53
  PageHeader,
36
54
  type BackLink,
@@ -39,6 +57,7 @@ export {
39
57
  Section,
40
58
  type ReadoutItem,
41
59
  } from "./Panel";
60
+ export { Popover, PopoverClose, type PopoverProps } from "./Popover";
42
61
  export {
43
62
  PoseInput,
44
63
  type PoseInputProps,
@@ -53,5 +72,15 @@ export { Slider } from "./Slider";
53
72
  export { Table, type Column } from "./Table";
54
73
  export { Checkbox, Switch } from "./Toggle";
55
74
  export { ToggleChip } from "./ToggleChip";
75
+ export { Toaster, type ToasterProps } from "./Toaster";
76
+ export {
77
+ createToastStore,
78
+ defaultToastStore,
79
+ toast,
80
+ type ToastOptions,
81
+ type ToastRecord,
82
+ type ToastStore,
83
+ type ToastTone,
84
+ } from "./toastStore";
56
85
  export { InfoHint, Tooltip, TooltipProvider } from "./Tooltip";
57
86
  export { VectorInput, type VectorInputProps } from "./VectorInput";
@@ -0,0 +1,63 @@
1
+ /*
2
+ * Keyboard movement in a single-select listbox, as a pure function so it is tested without a
3
+ * DOM. `Listbox` calls it on every key press.
4
+ */
5
+
6
+ /** The keys that move the active option. */
7
+ export type ListboxKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" | "PageUp";
8
+
9
+ /**
10
+ * The option a key moves the active one to, skipping disabled options.
11
+ *
12
+ * Arrows move by one and stop at the ends (no wrap: a long list that wraps loses its place).
13
+ * Home and End go to the first and last enabled option. Page keys move by `page` options.
14
+ * From no active option (`-1`), Down and Page Down go to the first enabled option, and Up and
15
+ * Page Up to the last.
16
+ *
17
+ * @param disabled - Per option, whether it is disabled.
18
+ * @param from - The active index, or -1.
19
+ * @param key - The key pressed.
20
+ * @param page - Options per page, for Page Up/Down. Defaults to 10.
21
+ * @returns The new active index; `from` when there is nowhere to go; -1 when every option is
22
+ * disabled.
23
+ */
24
+ export function moveActive(
25
+ disabled: readonly boolean[],
26
+ from: number,
27
+ key: ListboxKey,
28
+ page = 10,
29
+ ): number {
30
+ const enabled = disabled.flatMap((off, index) => (off ? [] : [index]));
31
+ if (enabled.length === 0) return -1;
32
+ const first = enabled[0]!;
33
+ const last = enabled[enabled.length - 1]!;
34
+ if (key === "Home") return first;
35
+ if (key === "End") return last;
36
+ if (from < 0) return key === "ArrowDown" || key === "PageDown" ? first : last;
37
+
38
+ const forward = key === "ArrowDown" || key === "PageDown";
39
+ const steps = key === "PageDown" || key === "PageUp" ? Math.max(1, page) : 1;
40
+ let target = from;
41
+ let moved = 0;
42
+ for (let index = from + (forward ? 1 : -1); index >= 0 && index < disabled.length; index += forward ? 1 : -1) {
43
+ if (disabled[index]) continue;
44
+ target = index;
45
+ moved += 1;
46
+ if (moved === steps) break;
47
+ }
48
+ return target;
49
+ }
50
+
51
+ /**
52
+ * The first enabled option at or after `index`, or before it when none is after. Used to place
53
+ * the active option when the list opens or changes.
54
+ *
55
+ * @param disabled - Per option, whether it is disabled.
56
+ * @param index - The preferred index.
57
+ * @returns An enabled index, or -1 when every option is disabled.
58
+ */
59
+ export function nearestEnabled(disabled: readonly boolean[], index: number): number {
60
+ for (let i = Math.max(0, index); i < disabled.length; i += 1) if (!disabled[i]) return i;
61
+ for (let i = Math.min(index, disabled.length) - 1; i >= 0; i -= 1) if (!disabled[i]) return i;
62
+ return -1;
63
+ }
@@ -1,6 +1,6 @@
1
1
  /*
2
- * Numbers as a field shows them and as a person types them. Internal to this package (used by
3
- * `VectorInput` and `PoseInput`); not exported.
2
+ * Numbers as a field shows them and as a person types them. Used by `NumberInput`'s
3
+ * number-valued mode, `VectorInput` and `PoseInput`, and exported for an app's own fields.
4
4
  *
5
5
  * A field bound straight to a number re-formats under the cursor: typing `0.` into a field
6
6
  * showing `0.000` becomes `0` before the next digit arrives. So the multi-number fields keep
@@ -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
  }