@vitavision/ui 0.7.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/README.md +18 -2
- package/dist/index.d.ts +220 -9
- package/dist/index.js +463 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/Input.tsx +67 -10
- package/src/components/ui/PoseInput.tsx +181 -0
- package/src/components/ui/Toaster.tsx +148 -0
- package/src/components/ui/VectorInput.tsx +183 -0
- package/src/components/ui/index.ts +20 -0
- package/src/components/ui/numberText.ts +36 -0
- package/src/components/ui/toastStore.ts +128 -0
- package/src/styles.css +19 -2
|
@@ -0,0 +1,36 @@
|
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* A field bound straight to a number re-formats under the cursor: typing `0.` into a field
|
|
6
|
+
* showing `0.000` becomes `0` before the next digit arrives. So the multi-number fields keep
|
|
7
|
+
* the typed text while they have focus and show the formatted value otherwise; these are the
|
|
8
|
+
* two conversions between the two.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
|
|
13
|
+
* error in a coordinate readout).
|
|
14
|
+
*
|
|
15
|
+
* @param value - The number. Non-finite values print as `NaN`, `Infinity`, `-Infinity`.
|
|
16
|
+
* @param precision - Decimals, 0–20.
|
|
17
|
+
* @returns The text.
|
|
18
|
+
*/
|
|
19
|
+
export function formatNumber(value: number, precision: number): string {
|
|
20
|
+
if (!Number.isFinite(value)) return String(value);
|
|
21
|
+
const text = value.toFixed(precision);
|
|
22
|
+
return /^-0(?:\.0*)?$/.test(text) ? text.slice(1) : text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The number a field's text means, if it means one.
|
|
27
|
+
*
|
|
28
|
+
* @param text - What is in the field.
|
|
29
|
+
* @returns The number, or `null` for empty, partial (`-`, `1e`) or non-numeric text.
|
|
30
|
+
*/
|
|
31
|
+
export function parseNumber(text: string): number | null {
|
|
32
|
+
const trimmed = text.trim();
|
|
33
|
+
if (trimmed === "") return null;
|
|
34
|
+
const value = Number(trimmed);
|
|
35
|
+
return Number.isFinite(value) ? value : null;
|
|
36
|
+
}
|
|
@@ -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
|
}
|