@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.
- package/README.md +19 -6
- package/dist/index.d.ts +326 -6
- package/dist/index.js +578 -28
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/components/ui/DropdownMenu.tsx +179 -0
- package/src/components/ui/Input.tsx +133 -8
- package/src/components/ui/Kbd.tsx +27 -0
- package/src/components/ui/Listbox.tsx +160 -0
- package/src/components/ui/Popover.tsx +96 -0
- package/src/components/ui/Toaster.tsx +148 -0
- package/src/components/ui/VectorInput.tsx +6 -2
- package/src/components/ui/index.ts +29 -0
- package/src/components/ui/listboxModel.ts +63 -0
- package/src/components/ui/numberText.ts +2 -2
- package/src/components/ui/toastStore.ts +128 -0
- package/src/styles.css +19 -2
|
@@ -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
|
|
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.
|
|
3
|
-
* `VectorInput` and `PoseInput
|
|
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:
|
|
181
|
+
animation-duration: 0s !important;
|
|
182
|
+
animation-delay: 0s !important;
|
|
167
183
|
animation-iteration-count: 1 !important;
|
|
168
|
-
transition-duration:
|
|
184
|
+
transition-duration: 0s !important;
|
|
185
|
+
transition-delay: 0s !important;
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
188
|
}
|