@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
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* can still pick up the shared palette and focus treatment.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { useId, type ComponentProps } from "react";
|
|
9
9
|
|
|
10
10
|
import { byDensity, useDensity } from "./Density";
|
|
11
11
|
import { useFieldDescription } from "./Field";
|
|
@@ -54,22 +54,46 @@ export function Input({ className, "aria-describedby": describedBy, ...rest }: C
|
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Props of `NumberInput`: every `<input>` prop, `ref` included, with numeric bounds and a `unit`. */
|
|
58
|
+
export type NumberInputProps = Omit<ComponentProps<"input">, "min" | "max"> & {
|
|
59
|
+
/** The smallest accepted value. */
|
|
60
|
+
min?: number | undefined;
|
|
61
|
+
/** The largest accepted value. */
|
|
62
|
+
max?: number | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The quantity's unit (`mm`, `m`, `°`, `px`), written inside the field after the number, in
|
|
65
|
+
* mono and muted. It is not part of the value. It is announced as the field's description
|
|
66
|
+
* (`aria-describedby`): after the caller's own `aria-describedby`, before a surrounding
|
|
67
|
+
* `Field`'s description and error.
|
|
68
|
+
*/
|
|
69
|
+
unit?: string | undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
|
-
* A number, with the schema's own bounds attached
|
|
73
|
+
* A number, with the schema's own bounds attached and, optionally, its unit written in the
|
|
74
|
+
* field.
|
|
59
75
|
*
|
|
60
76
|
* `font-mono` because these are read as quantities and compared down a column. Takes every
|
|
61
|
-
* `<input>` prop, `ref` included (`type` is always `number`)
|
|
77
|
+
* `<input>` prop, `ref` included (`type` is always `number`); `className`, `style` and `ref`
|
|
78
|
+
* always go to the `<input>`.
|
|
79
|
+
*
|
|
80
|
+
* With a `unit`, the input sits in a `relative` full-width wrapper carrying `data-unit`, keeps
|
|
81
|
+
* room on its right for the unit, and is described by it; size such a field through its
|
|
82
|
+
* container. Without one — or with `""` — the markup is exactly the plain input.
|
|
62
83
|
*/
|
|
63
|
-
export function NumberInput({
|
|
84
|
+
export function NumberInput({ unit, ...props }: NumberInputProps) {
|
|
85
|
+
return unit === undefined || unit === "" ? (
|
|
86
|
+
<PlainNumberInput {...props} />
|
|
87
|
+
) : (
|
|
88
|
+
<UnitNumberInput unit={unit} {...props} />
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function PlainNumberInput({
|
|
64
93
|
className,
|
|
65
94
|
"aria-describedby": describedBy,
|
|
66
95
|
...rest
|
|
67
|
-
}: Omit<
|
|
68
|
-
/** The smallest accepted value. */
|
|
69
|
-
min?: number | undefined;
|
|
70
|
-
/** The largest accepted value. */
|
|
71
|
-
max?: number | undefined;
|
|
72
|
-
}) {
|
|
96
|
+
}: Omit<NumberInputProps, "unit">) {
|
|
73
97
|
return (
|
|
74
98
|
<input
|
|
75
99
|
{...useFieldDescription(describedBy)}
|
|
@@ -81,6 +105,39 @@ export function NumberInput({
|
|
|
81
105
|
);
|
|
82
106
|
}
|
|
83
107
|
|
|
108
|
+
/*
|
|
109
|
+
* A component of its own so that only a field with a unit calls `useId`: a plain
|
|
110
|
+
* `NumberInput` stays byte-identical to the one before `unit` existed, down to the ids React
|
|
111
|
+
* hands the elements rendered after it.
|
|
112
|
+
*/
|
|
113
|
+
function UnitNumberInput({
|
|
114
|
+
unit,
|
|
115
|
+
style,
|
|
116
|
+
"aria-describedby": describedBy,
|
|
117
|
+
...rest
|
|
118
|
+
}: Omit<NumberInputProps, "unit"> & { unit: string }) {
|
|
119
|
+
const unitId = useId();
|
|
120
|
+
return (
|
|
121
|
+
<span data-unit={unit} className="relative flex w-full min-w-0 items-center">
|
|
122
|
+
<PlainNumberInput
|
|
123
|
+
{...rest}
|
|
124
|
+
aria-describedby={describedBy ? `${describedBy} ${unitId}` : unitId}
|
|
125
|
+
// Room for the unit: its width in the unit's own characters, plus the field's padding.
|
|
126
|
+
style={{ paddingInlineEnd: `calc(${unit.length}ch + 1rem)`, ...style }}
|
|
127
|
+
/>
|
|
128
|
+
{/* `aria-hidden` keeps the unit out of the field's *name* when a `<label>` wraps it;
|
|
129
|
+
`aria-describedby` still reads it, as the description. */}
|
|
130
|
+
<span
|
|
131
|
+
id={unitId}
|
|
132
|
+
aria-hidden
|
|
133
|
+
className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center font-mono text-xs text-fg-subtle select-none"
|
|
134
|
+
>
|
|
135
|
+
{unit}
|
|
136
|
+
</span>
|
|
137
|
+
</span>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
84
141
|
/**
|
|
85
142
|
* Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
|
|
86
143
|
* included.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A rigid transform, edited the way people think about one.
|
|
3
|
+
*
|
|
4
|
+
* Poses travel as `{ rotation: [qx, qy, qz, qw], translation: [tx, ty, tz] }` — the SE(3) wire
|
|
5
|
+
* form — because a quaternion is the honest representation. Nobody edits one by hand,
|
|
6
|
+
* though: a rotation is read and typed as roll, pitch and yaw in degrees. This component
|
|
7
|
+
* shows that view and writes back the wire form.
|
|
8
|
+
*
|
|
9
|
+
* It owns no rotation mathematics. Which Euler convention "roll, pitch, yaw" means — the
|
|
10
|
+
* axis order, intrinsic or extrinsic — is a decision of the 3D package that defines the
|
|
11
|
+
* frames, so the conversion is passed in as a `RotationView`. This package stays pure UI.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
|
|
16
|
+
import { byDensity, useDensity } from "./Density";
|
|
17
|
+
import { ReadoutStrip } from "./Panel";
|
|
18
|
+
import { cn } from "./cn";
|
|
19
|
+
import { formatNumber } from "./numberText";
|
|
20
|
+
import { VectorInput } from "./VectorInput";
|
|
21
|
+
|
|
22
|
+
/** Three numbers: a translation `[tx, ty, tz]`, or Euler angles. */
|
|
23
|
+
export type Vec3 = readonly [number, number, number];
|
|
24
|
+
|
|
25
|
+
/** A unit quaternion in the wire order `[qx, qy, qz, qw]` (scalar last). */
|
|
26
|
+
export type Quaternion = readonly [number, number, number, number];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An SE(3) pose in the wire form (`a_se3_b` as nalgebra's `Isometry3` serialises it):
|
|
30
|
+
* translation in metres, rotation as a unit quaternion `[qx, qy, qz, qw]`.
|
|
31
|
+
*/
|
|
32
|
+
export interface PoseValue {
|
|
33
|
+
/** The rotation, `[qx, qy, qz, qw]`. */
|
|
34
|
+
rotation: Quaternion;
|
|
35
|
+
/** The translation `[tx, ty, tz]`, in metres. */
|
|
36
|
+
translation: Vec3;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How a rotation is shown as three angles: the conversion both ways, and the angles' names.
|
|
41
|
+
* Supplied by the package that owns the frame conventions; `PoseInput` only calls it.
|
|
42
|
+
*/
|
|
43
|
+
export interface RotationView {
|
|
44
|
+
/** The quaternion as three angles, in radians, in the order of `labels`. */
|
|
45
|
+
toEuler: (rotation: Quaternion) => Vec3;
|
|
46
|
+
/** Three angles in radians, in the order of `labels`, as a unit quaternion `[qx, qy, qz, qw]`. */
|
|
47
|
+
fromEuler: (angles: Vec3) => Quaternion;
|
|
48
|
+
/** The angles' names, shown before each field. Defaults to roll, pitch, yaw. */
|
|
49
|
+
labels?: readonly [string, string, string] | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Props of `PoseInput`. */
|
|
53
|
+
export interface PoseInputProps {
|
|
54
|
+
/** The pose, in the wire form. */
|
|
55
|
+
value: PoseValue;
|
|
56
|
+
/** Called with a new pose (new arrays) whenever a component changes. */
|
|
57
|
+
onValueChange?: ((value: PoseValue) => void) | undefined;
|
|
58
|
+
/** The quaternion ↔ angles conversion and the angles' names. Required: see `RotationView`. */
|
|
59
|
+
rotationView: RotationView;
|
|
60
|
+
/** Show and edit the translation in metres (the default) or millimetres. The value is always metres. */
|
|
61
|
+
translationUnit?: "m" | "mm" | undefined;
|
|
62
|
+
/** Decimals of the translation. Defaults to 4 for m, 1 for mm. */
|
|
63
|
+
translationPrecision?: number | undefined;
|
|
64
|
+
/** Decimals of the angles, in degrees. Defaults to 2. */
|
|
65
|
+
rotationPrecision?: number | undefined;
|
|
66
|
+
/** Show a compact mono readout instead of fields. */
|
|
67
|
+
readOnly?: boolean | undefined;
|
|
68
|
+
/** Blocks every field. */
|
|
69
|
+
disabled?: boolean | undefined;
|
|
70
|
+
/** Names the pose (`role="group"`). Defaults to "Pose". */
|
|
71
|
+
"aria-label"?: string | undefined;
|
|
72
|
+
/** Merged with the component's own classes through `cn`. */
|
|
73
|
+
className?: string | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEGREES = 180 / Math.PI;
|
|
77
|
+
const DEFAULT_ANGLE_LABELS = ["roll", "pitch", "yaw"] as const;
|
|
78
|
+
|
|
79
|
+
const sameQuaternion = (a: Quaternion, b: Quaternion) => a.every((component, index) => component === b[index]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* An SE(3) pose shown and edited as a translation (m or mm) and three angles in degrees,
|
|
83
|
+
* reading and writing the wire form `{ rotation: [qx, qy, qz, qw], translation: [tx, ty, tz] }`.
|
|
84
|
+
*
|
|
85
|
+
* The angles come from `rotationView.toEuler` and go back through `rotationView.fromEuler`.
|
|
86
|
+
* The angles as typed are kept while the pose they produced is the one shown, so a yaw of
|
|
87
|
+
* 190° is not redisplayed as −170° and a gimbal-locked pitch does not scramble the other two
|
|
88
|
+
* angles mid-edit.
|
|
89
|
+
*
|
|
90
|
+
* A `role="group"` named by `aria-label`, holding two `VectorInput` groups ("Translation",
|
|
91
|
+
* "Rotation"). With `readOnly` it renders a `ReadoutStrip` instead
|
|
92
|
+
* (`t 0.1000 0.2000 0.3000 m · rpy 0.00 90.00 0.00 °`). The root carries `data-readonly`.
|
|
93
|
+
*/
|
|
94
|
+
export function PoseInput({
|
|
95
|
+
value,
|
|
96
|
+
onValueChange,
|
|
97
|
+
rotationView,
|
|
98
|
+
translationUnit = "m",
|
|
99
|
+
translationPrecision,
|
|
100
|
+
rotationPrecision = 2,
|
|
101
|
+
readOnly = false,
|
|
102
|
+
disabled = false,
|
|
103
|
+
"aria-label": ariaLabel = "Pose",
|
|
104
|
+
className,
|
|
105
|
+
}: PoseInputProps) {
|
|
106
|
+
const density = useDensity();
|
|
107
|
+
// The angles last typed, and the quaternion they became.
|
|
108
|
+
const [typed, setTyped] = useState<{ rotation: Quaternion; angles: Vec3 } | null>(null);
|
|
109
|
+
|
|
110
|
+
const scale = translationUnit === "mm" ? 1000 : 1;
|
|
111
|
+
const tPrecision = translationPrecision ?? (translationUnit === "mm" ? 1 : 4);
|
|
112
|
+
const translation = value.translation.map((component) => component * scale);
|
|
113
|
+
const radians =
|
|
114
|
+
typed !== null && sameQuaternion(typed.rotation, value.rotation) ? typed.angles : rotationView.toEuler(value.rotation);
|
|
115
|
+
const degrees = radians.map((angle) => angle * DEGREES);
|
|
116
|
+
const angleLabels = rotationView.labels ?? DEFAULT_ANGLE_LABELS;
|
|
117
|
+
|
|
118
|
+
if (readOnly) {
|
|
119
|
+
const join = (values: readonly number[], precision: number) =>
|
|
120
|
+
values.map((component) => formatNumber(component, precision)).join(" ");
|
|
121
|
+
return (
|
|
122
|
+
<div role="group" aria-label={ariaLabel} data-readonly="" className={cn("min-w-0", className)}>
|
|
123
|
+
<ReadoutStrip
|
|
124
|
+
items={[
|
|
125
|
+
{ label: "t", value: `${join(translation, tPrecision)} ${translationUnit}` },
|
|
126
|
+
{ label: "rpy", value: `${join(degrees, rotationPrecision)} °` },
|
|
127
|
+
]}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const onTranslation = (next: number[]) => {
|
|
134
|
+
const [x = 0, y = 0, z = 0] = next.map((component) => component / scale);
|
|
135
|
+
onValueChange?.({ rotation: value.rotation, translation: [x, y, z] });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onRotation = (next: number[]) => {
|
|
139
|
+
const [a = 0, b = 0, c = 0] = next.map((angle) => angle / DEGREES);
|
|
140
|
+
const angles: Vec3 = [a, b, c];
|
|
141
|
+
const rotation = rotationView.fromEuler(angles);
|
|
142
|
+
setTyped({ rotation, angles });
|
|
143
|
+
onValueChange?.({ rotation, translation: value.translation });
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const rowLabel = cn("text-fg-muted", byDensity(density, "text-xs", "text-[11px]"));
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
role="group"
|
|
151
|
+
aria-label={ariaLabel}
|
|
152
|
+
className={cn("grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2", byDensity(density, "gap-y-2", "gap-y-1"), className)}
|
|
153
|
+
>
|
|
154
|
+
<span aria-hidden className={rowLabel}>
|
|
155
|
+
Translation
|
|
156
|
+
</span>
|
|
157
|
+
<VectorInput
|
|
158
|
+
aria-label="Translation"
|
|
159
|
+
value={translation}
|
|
160
|
+
onValueChange={onTranslation}
|
|
161
|
+
unit={translationUnit}
|
|
162
|
+
precision={tPrecision}
|
|
163
|
+
step={translationUnit === "mm" ? 1 : 0.001}
|
|
164
|
+
disabled={disabled}
|
|
165
|
+
/>
|
|
166
|
+
<span aria-hidden className={rowLabel}>
|
|
167
|
+
Rotation
|
|
168
|
+
</span>
|
|
169
|
+
<VectorInput
|
|
170
|
+
aria-label="Rotation"
|
|
171
|
+
value={degrees}
|
|
172
|
+
onValueChange={onRotation}
|
|
173
|
+
labels={angleLabels}
|
|
174
|
+
unit="°"
|
|
175
|
+
precision={rotationPrecision}
|
|
176
|
+
step={1}
|
|
177
|
+
disabled={disabled}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A small vector — a position, a set of angles, a scale — edited as one row.
|
|
3
|
+
*
|
|
4
|
+
* Three separate `Field`s for x, y and z spend three labels and three rows on what a reader
|
|
5
|
+
* thinks of as one quantity. This is one row: a quiet axis letter before each number, the
|
|
6
|
+
* unit once at the end, and one name for the whole group.
|
|
7
|
+
*
|
|
8
|
+
* Each field keeps the text being typed while it has focus and shows the formatted value
|
|
9
|
+
* otherwise (see `numberText.ts`), so `precision` never fights the cursor. Every valid
|
|
10
|
+
* keystroke is reported, so whatever the vector drives follows the typing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useId, useState } from "react";
|
|
14
|
+
import type { KeyboardEvent } from "react";
|
|
15
|
+
|
|
16
|
+
import { NumberInput } from "./Input";
|
|
17
|
+
import { ReadoutStrip } from "./Panel";
|
|
18
|
+
import { cn } from "./cn";
|
|
19
|
+
import { formatNumber, parseNumber } from "./numberText";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_LABELS = ["x", "y", "z", "w"];
|
|
22
|
+
|
|
23
|
+
/** Props of `VectorInput`. */
|
|
24
|
+
export interface VectorInputProps {
|
|
25
|
+
/** The components. Its length is the number of fields. */
|
|
26
|
+
value: readonly number[];
|
|
27
|
+
/** Called with the whole vector (a new array) whenever one component changes to a valid number. */
|
|
28
|
+
onValueChange?: ((value: number[]) => void) | undefined;
|
|
29
|
+
/** Each component's label, shown before its field and naming it. Defaults to x, y, z, w. */
|
|
30
|
+
labels?: readonly string[] | undefined;
|
|
31
|
+
/** The unit of every component, shown once after the row (`m`, `mm`, `°`). */
|
|
32
|
+
unit?: string | undefined;
|
|
33
|
+
/** The arrow-key step. Defaults to `any` (the browser then steps by 1). */
|
|
34
|
+
step?: number | undefined;
|
|
35
|
+
/** Decimals shown when a field is not being edited. Defaults to 3. */
|
|
36
|
+
precision?: number | undefined;
|
|
37
|
+
/** The smallest accepted component. */
|
|
38
|
+
min?: number | undefined;
|
|
39
|
+
/** The largest accepted component. */
|
|
40
|
+
max?: number | undefined;
|
|
41
|
+
/** Show the values as a compact mono readout instead of fields. */
|
|
42
|
+
readOnly?: boolean | undefined;
|
|
43
|
+
/** Blocks every field. */
|
|
44
|
+
disabled?: boolean | undefined;
|
|
45
|
+
/** Names the group (`role="group"`). */
|
|
46
|
+
"aria-label": string;
|
|
47
|
+
/** Merged with the row's own classes through `cn`. */
|
|
48
|
+
className?: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A row of number fields editing one small vector, with the axis labels before the fields
|
|
53
|
+
* and the unit once after them.
|
|
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
|
|
57
|
+
* shows the value at `precision`; Escape restores the value it had on focus. With `readOnly`
|
|
58
|
+
* it renders a `ReadoutStrip` (`x 0.100 m · y …`) instead. The row carries `data-readonly`
|
|
59
|
+
* and `data-disabled`.
|
|
60
|
+
*/
|
|
61
|
+
export function VectorInput({
|
|
62
|
+
value,
|
|
63
|
+
onValueChange,
|
|
64
|
+
labels = DEFAULT_LABELS,
|
|
65
|
+
unit,
|
|
66
|
+
step,
|
|
67
|
+
precision = 3,
|
|
68
|
+
min,
|
|
69
|
+
max,
|
|
70
|
+
readOnly = false,
|
|
71
|
+
disabled = false,
|
|
72
|
+
"aria-label": ariaLabel,
|
|
73
|
+
className,
|
|
74
|
+
}: VectorInputProps) {
|
|
75
|
+
const unitId = useId();
|
|
76
|
+
const labelOf = (index: number) => labels[index] ?? String(index);
|
|
77
|
+
|
|
78
|
+
if (readOnly) {
|
|
79
|
+
return (
|
|
80
|
+
<div role="group" aria-label={ariaLabel} data-readonly="" className={cn("min-w-0", className)}>
|
|
81
|
+
<ReadoutStrip
|
|
82
|
+
items={value.map((component, index) => ({
|
|
83
|
+
label: labelOf(index),
|
|
84
|
+
value: unit ? `${formatNumber(component, precision)} ${unit}` : formatNumber(component, precision),
|
|
85
|
+
}))}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const change = (index: number, component: number) => {
|
|
92
|
+
if (component === value[index]) return;
|
|
93
|
+
onValueChange?.(value.map((current, i) => (i === index ? component : current)));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
role="group"
|
|
99
|
+
aria-label={ariaLabel}
|
|
100
|
+
data-disabled={disabled ? "" : undefined}
|
|
101
|
+
className={cn("flex min-w-0 items-center gap-1.5", className)}
|
|
102
|
+
>
|
|
103
|
+
{value.map((component, index) => (
|
|
104
|
+
// The components are positional: index is their identity.
|
|
105
|
+
// eslint-disable-next-line @eslint-react/no-array-index-key -- see above
|
|
106
|
+
<label key={index} className="flex min-w-0 flex-1 items-center gap-1">
|
|
107
|
+
<span className="shrink-0 font-mono text-[11px] text-fg-subtle">{labelOf(index)}</span>
|
|
108
|
+
<ComponentField
|
|
109
|
+
value={component}
|
|
110
|
+
precision={precision}
|
|
111
|
+
step={step}
|
|
112
|
+
min={min}
|
|
113
|
+
max={max}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
describedBy={unit ? unitId : undefined}
|
|
116
|
+
onCommit={(next) => change(index, next)}
|
|
117
|
+
/>
|
|
118
|
+
</label>
|
|
119
|
+
))}
|
|
120
|
+
{unit && (
|
|
121
|
+
<span id={unitId} className="shrink-0 font-mono text-xs text-fg-subtle">
|
|
122
|
+
{unit}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One component: formatted at rest, the typed text while focused. */
|
|
130
|
+
function ComponentField({
|
|
131
|
+
value,
|
|
132
|
+
precision,
|
|
133
|
+
step,
|
|
134
|
+
min,
|
|
135
|
+
max,
|
|
136
|
+
disabled,
|
|
137
|
+
describedBy,
|
|
138
|
+
onCommit,
|
|
139
|
+
}: {
|
|
140
|
+
value: number;
|
|
141
|
+
precision: number;
|
|
142
|
+
step: number | undefined;
|
|
143
|
+
min: number | undefined;
|
|
144
|
+
max: number | undefined;
|
|
145
|
+
disabled: boolean;
|
|
146
|
+
describedBy: string | undefined;
|
|
147
|
+
onCommit: (value: number) => void;
|
|
148
|
+
}) {
|
|
149
|
+
// Non-null while the field is being edited: the text as typed, and the value on focus.
|
|
150
|
+
const [draft, setDraft] = useState<{ text: string; initial: number } | null>(null);
|
|
151
|
+
const formatted = formatNumber(value, precision);
|
|
152
|
+
|
|
153
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
154
|
+
if (event.key === "Enter") {
|
|
155
|
+
setDraft((current) => current && { ...current, text: formatted });
|
|
156
|
+
} else if (event.key === "Escape" && draft) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
onCommit(draft.initial);
|
|
159
|
+
setDraft({ ...draft, text: formatNumber(draft.initial, precision) });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<NumberInput
|
|
165
|
+
value={draft?.text ?? formatted}
|
|
166
|
+
step={step ?? "any"}
|
|
167
|
+
min={min}
|
|
168
|
+
max={max}
|
|
169
|
+
disabled={disabled}
|
|
170
|
+
aria-describedby={describedBy}
|
|
171
|
+
onFocus={() => setDraft({ text: formatted, initial: value })}
|
|
172
|
+
onBlur={() => setDraft(null)}
|
|
173
|
+
onKeyDown={onKeyDown}
|
|
174
|
+
onChange={(event) => {
|
|
175
|
+
const text = event.currentTarget.value;
|
|
176
|
+
setDraft((current) => ({ text, initial: current?.initial ?? value }));
|
|
177
|
+
const parsed = parseNumber(text);
|
|
178
|
+
if (parsed !== null) onCommit(parsed);
|
|
179
|
+
}}
|
|
180
|
+
className="min-w-0 px-1.5"
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
controlClasses,
|
|
30
30
|
inputClasses,
|
|
31
31
|
useControlHeight,
|
|
32
|
+
type NumberInputProps,
|
|
32
33
|
} from "./Input";
|
|
33
34
|
export {
|
|
34
35
|
PageHeader,
|
|
@@ -38,10 +39,29 @@ export {
|
|
|
38
39
|
Section,
|
|
39
40
|
type ReadoutItem,
|
|
40
41
|
} from "./Panel";
|
|
42
|
+
export {
|
|
43
|
+
PoseInput,
|
|
44
|
+
type PoseInputProps,
|
|
45
|
+
type PoseValue,
|
|
46
|
+
type Quaternion,
|
|
47
|
+
type RotationView,
|
|
48
|
+
type Vec3,
|
|
49
|
+
} from "./PoseInput";
|
|
41
50
|
export { SegmentedControl } from "./SegmentedControl";
|
|
42
51
|
export { Select, type SelectOption } from "./Select";
|
|
43
52
|
export { Slider } from "./Slider";
|
|
44
53
|
export { Table, type Column } from "./Table";
|
|
45
54
|
export { Checkbox, Switch } from "./Toggle";
|
|
46
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";
|
|
47
66
|
export { InfoHint, Tooltip, TooltipProvider } from "./Tooltip";
|
|
67
|
+
export { VectorInput, type VectorInputProps } from "./VectorInput";
|