@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/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, XCircle } from "lucide-react";
|
|
2
|
-
import { createContext, isValidElement, use, useCallback, useId, useMemo, useState, useSyncExternalStore } from "react";
|
|
1
|
+
import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, CircleCheck, CircleX, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, TriangleAlert, X, XCircle } from "lucide-react";
|
|
2
|
+
import { createContext, isValidElement, use, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
3
|
import * as RadixTooltip from "@radix-ui/react-tooltip";
|
|
4
4
|
import { clsx } from "clsx";
|
|
5
5
|
import { twMerge } from "tailwind-merge";
|
|
@@ -813,12 +813,24 @@ function Input({ className, "aria-describedby": describedBy, ...rest }) {
|
|
|
813
813
|
});
|
|
814
814
|
}
|
|
815
815
|
/**
|
|
816
|
-
* A number, with the schema's own bounds attached
|
|
816
|
+
* A number, with the schema's own bounds attached and, optionally, its unit written in the
|
|
817
|
+
* field.
|
|
817
818
|
*
|
|
818
819
|
* `font-mono` because these are read as quantities and compared down a column. Takes every
|
|
819
|
-
* `<input>` prop, `ref` included (`type` is always `number`)
|
|
820
|
+
* `<input>` prop, `ref` included (`type` is always `number`); `className`, `style` and `ref`
|
|
821
|
+
* always go to the `<input>`.
|
|
822
|
+
*
|
|
823
|
+
* With a `unit`, the input sits in a `relative` full-width wrapper carrying `data-unit`, keeps
|
|
824
|
+
* room on its right for the unit, and is described by it; size such a field through its
|
|
825
|
+
* container. Without one — or with `""` — the markup is exactly the plain input.
|
|
820
826
|
*/
|
|
821
|
-
function NumberInput({
|
|
827
|
+
function NumberInput({ unit, ...props }) {
|
|
828
|
+
return unit === void 0 || unit === "" ? /* @__PURE__ */ jsx(PlainNumberInput, { ...props }) : /* @__PURE__ */ jsx(UnitNumberInput, {
|
|
829
|
+
unit,
|
|
830
|
+
...props
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function PlainNumberInput({ className, "aria-describedby": describedBy, ...rest }) {
|
|
822
834
|
return /* @__PURE__ */ jsx("input", {
|
|
823
835
|
...useFieldDescription(describedBy),
|
|
824
836
|
...rest,
|
|
@@ -827,6 +839,26 @@ function NumberInput({ className, "aria-describedby": describedBy, ...rest }) {
|
|
|
827
839
|
className: cn(controlClasses, useControlHeight(), "font-mono tabular-nums", className)
|
|
828
840
|
});
|
|
829
841
|
}
|
|
842
|
+
function UnitNumberInput({ unit, style, "aria-describedby": describedBy, ...rest }) {
|
|
843
|
+
const unitId = useId();
|
|
844
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
845
|
+
"data-unit": unit,
|
|
846
|
+
className: "relative flex w-full min-w-0 items-center",
|
|
847
|
+
children: [/* @__PURE__ */ jsx(PlainNumberInput, {
|
|
848
|
+
...rest,
|
|
849
|
+
"aria-describedby": describedBy ? `${describedBy} ${unitId}` : unitId,
|
|
850
|
+
style: {
|
|
851
|
+
paddingInlineEnd: `calc(${unit.length}ch + 1rem)`,
|
|
852
|
+
...style
|
|
853
|
+
}
|
|
854
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
855
|
+
id: unitId,
|
|
856
|
+
"aria-hidden": true,
|
|
857
|
+
className: "pointer-events-none absolute inset-y-0 right-2.5 flex items-center font-mono text-xs text-fg-subtle select-none",
|
|
858
|
+
children: unit
|
|
859
|
+
})]
|
|
860
|
+
});
|
|
861
|
+
}
|
|
830
862
|
/**
|
|
831
863
|
* Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
|
|
832
864
|
* included.
|
|
@@ -974,6 +1006,250 @@ function ReadoutStrip({ items, className }) {
|
|
|
974
1006
|
});
|
|
975
1007
|
}
|
|
976
1008
|
//#endregion
|
|
1009
|
+
//#region src/components/ui/numberText.ts
|
|
1010
|
+
/**
|
|
1011
|
+
* A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
|
|
1012
|
+
* error in a coordinate readout).
|
|
1013
|
+
*
|
|
1014
|
+
* @param value - The number. Non-finite values print as `NaN`, `Infinity`, `-Infinity`.
|
|
1015
|
+
* @param precision - Decimals, 0–20.
|
|
1016
|
+
* @returns The text.
|
|
1017
|
+
*/
|
|
1018
|
+
function formatNumber(value, precision) {
|
|
1019
|
+
if (!Number.isFinite(value)) return String(value);
|
|
1020
|
+
const text = value.toFixed(precision);
|
|
1021
|
+
return /^-0(?:\.0*)?$/.test(text) ? text.slice(1) : text;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* The number a field's text means, if it means one.
|
|
1025
|
+
*
|
|
1026
|
+
* @param text - What is in the field.
|
|
1027
|
+
* @returns The number, or `null` for empty, partial (`-`, `1e`) or non-numeric text.
|
|
1028
|
+
*/
|
|
1029
|
+
function parseNumber(text) {
|
|
1030
|
+
const trimmed = text.trim();
|
|
1031
|
+
if (trimmed === "") return null;
|
|
1032
|
+
const value = Number(trimmed);
|
|
1033
|
+
return Number.isFinite(value) ? value : null;
|
|
1034
|
+
}
|
|
1035
|
+
//#endregion
|
|
1036
|
+
//#region src/components/ui/VectorInput.tsx
|
|
1037
|
+
const DEFAULT_LABELS = [
|
|
1038
|
+
"x",
|
|
1039
|
+
"y",
|
|
1040
|
+
"z",
|
|
1041
|
+
"w"
|
|
1042
|
+
];
|
|
1043
|
+
/**
|
|
1044
|
+
* A row of number fields editing one small vector, with the axis labels before the fields
|
|
1045
|
+
* and the unit once after them.
|
|
1046
|
+
*
|
|
1047
|
+
* Controlled. A `role="group"` named by `aria-label`, each field named by its label and
|
|
1048
|
+
* described by the unit. While a field has focus it shows what is typed; on blur or Enter it
|
|
1049
|
+
* shows the value at `precision`; Escape restores the value it had on focus. With `readOnly`
|
|
1050
|
+
* it renders a `ReadoutStrip` (`x 0.100 m · y …`) instead. The row carries `data-readonly`
|
|
1051
|
+
* and `data-disabled`.
|
|
1052
|
+
*/
|
|
1053
|
+
function VectorInput({ value, onValueChange, labels = DEFAULT_LABELS, unit, step, precision = 3, min, max, readOnly = false, disabled = false, "aria-label": ariaLabel, className }) {
|
|
1054
|
+
const unitId = useId();
|
|
1055
|
+
const labelOf = (index) => labels[index] ?? String(index);
|
|
1056
|
+
if (readOnly) return /* @__PURE__ */ jsx("div", {
|
|
1057
|
+
role: "group",
|
|
1058
|
+
"aria-label": ariaLabel,
|
|
1059
|
+
"data-readonly": "",
|
|
1060
|
+
className: cn("min-w-0", className),
|
|
1061
|
+
children: /* @__PURE__ */ jsx(ReadoutStrip, { items: value.map((component, index) => ({
|
|
1062
|
+
label: labelOf(index),
|
|
1063
|
+
value: unit ? `${formatNumber(component, precision)} ${unit}` : formatNumber(component, precision)
|
|
1064
|
+
})) })
|
|
1065
|
+
});
|
|
1066
|
+
const change = (index, component) => {
|
|
1067
|
+
if (component === value[index]) return;
|
|
1068
|
+
onValueChange?.(value.map((current, i) => i === index ? component : current));
|
|
1069
|
+
};
|
|
1070
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1071
|
+
role: "group",
|
|
1072
|
+
"aria-label": ariaLabel,
|
|
1073
|
+
"data-disabled": disabled ? "" : void 0,
|
|
1074
|
+
className: cn("flex min-w-0 items-center gap-1.5", className),
|
|
1075
|
+
children: [value.map((component, index) => /* @__PURE__ */ jsxs("label", {
|
|
1076
|
+
className: "flex min-w-0 flex-1 items-center gap-1",
|
|
1077
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1078
|
+
className: "shrink-0 font-mono text-[11px] text-fg-subtle",
|
|
1079
|
+
children: labelOf(index)
|
|
1080
|
+
}), /* @__PURE__ */ jsx(ComponentField, {
|
|
1081
|
+
value: component,
|
|
1082
|
+
precision,
|
|
1083
|
+
step,
|
|
1084
|
+
min,
|
|
1085
|
+
max,
|
|
1086
|
+
disabled,
|
|
1087
|
+
describedBy: unit ? unitId : void 0,
|
|
1088
|
+
onCommit: (next) => change(index, next)
|
|
1089
|
+
})]
|
|
1090
|
+
}, index)), unit && /* @__PURE__ */ jsx("span", {
|
|
1091
|
+
id: unitId,
|
|
1092
|
+
className: "shrink-0 font-mono text-xs text-fg-subtle",
|
|
1093
|
+
children: unit
|
|
1094
|
+
})]
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/** One component: formatted at rest, the typed text while focused. */
|
|
1098
|
+
function ComponentField({ value, precision, step, min, max, disabled, describedBy, onCommit }) {
|
|
1099
|
+
const [draft, setDraft] = useState(null);
|
|
1100
|
+
const formatted = formatNumber(value, precision);
|
|
1101
|
+
const onKeyDown = (event) => {
|
|
1102
|
+
if (event.key === "Enter") setDraft((current) => current && {
|
|
1103
|
+
...current,
|
|
1104
|
+
text: formatted
|
|
1105
|
+
});
|
|
1106
|
+
else if (event.key === "Escape" && draft) {
|
|
1107
|
+
event.preventDefault();
|
|
1108
|
+
onCommit(draft.initial);
|
|
1109
|
+
setDraft({
|
|
1110
|
+
...draft,
|
|
1111
|
+
text: formatNumber(draft.initial, precision)
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
return /* @__PURE__ */ jsx(NumberInput, {
|
|
1116
|
+
value: draft?.text ?? formatted,
|
|
1117
|
+
step: step ?? "any",
|
|
1118
|
+
min,
|
|
1119
|
+
max,
|
|
1120
|
+
disabled,
|
|
1121
|
+
"aria-describedby": describedBy,
|
|
1122
|
+
onFocus: () => setDraft({
|
|
1123
|
+
text: formatted,
|
|
1124
|
+
initial: value
|
|
1125
|
+
}),
|
|
1126
|
+
onBlur: () => setDraft(null),
|
|
1127
|
+
onKeyDown,
|
|
1128
|
+
onChange: (event) => {
|
|
1129
|
+
const text = event.currentTarget.value;
|
|
1130
|
+
setDraft((current) => ({
|
|
1131
|
+
text,
|
|
1132
|
+
initial: current?.initial ?? value
|
|
1133
|
+
}));
|
|
1134
|
+
const parsed = parseNumber(text);
|
|
1135
|
+
if (parsed !== null) onCommit(parsed);
|
|
1136
|
+
},
|
|
1137
|
+
className: "min-w-0 px-1.5"
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
//#endregion
|
|
1141
|
+
//#region src/components/ui/PoseInput.tsx
|
|
1142
|
+
const DEGREES = 180 / Math.PI;
|
|
1143
|
+
const DEFAULT_ANGLE_LABELS = [
|
|
1144
|
+
"roll",
|
|
1145
|
+
"pitch",
|
|
1146
|
+
"yaw"
|
|
1147
|
+
];
|
|
1148
|
+
const sameQuaternion = (a, b) => a.every((component, index) => component === b[index]);
|
|
1149
|
+
/**
|
|
1150
|
+
* An SE(3) pose shown and edited as a translation (m or mm) and three angles in degrees,
|
|
1151
|
+
* reading and writing the wire form `{ rotation: [qx, qy, qz, qw], translation: [tx, ty, tz] }`.
|
|
1152
|
+
*
|
|
1153
|
+
* The angles come from `rotationView.toEuler` and go back through `rotationView.fromEuler`.
|
|
1154
|
+
* The angles as typed are kept while the pose they produced is the one shown, so a yaw of
|
|
1155
|
+
* 190° is not redisplayed as −170° and a gimbal-locked pitch does not scramble the other two
|
|
1156
|
+
* angles mid-edit.
|
|
1157
|
+
*
|
|
1158
|
+
* A `role="group"` named by `aria-label`, holding two `VectorInput` groups ("Translation",
|
|
1159
|
+
* "Rotation"). With `readOnly` it renders a `ReadoutStrip` instead
|
|
1160
|
+
* (`t 0.1000 0.2000 0.3000 m · rpy 0.00 90.00 0.00 °`). The root carries `data-readonly`.
|
|
1161
|
+
*/
|
|
1162
|
+
function PoseInput({ value, onValueChange, rotationView, translationUnit = "m", translationPrecision, rotationPrecision = 2, readOnly = false, disabled = false, "aria-label": ariaLabel = "Pose", className }) {
|
|
1163
|
+
const density = useDensity();
|
|
1164
|
+
const [typed, setTyped] = useState(null);
|
|
1165
|
+
const scale = translationUnit === "mm" ? 1e3 : 1;
|
|
1166
|
+
const tPrecision = translationPrecision ?? (translationUnit === "mm" ? 1 : 4);
|
|
1167
|
+
const translation = value.translation.map((component) => component * scale);
|
|
1168
|
+
const degrees = (typed !== null && sameQuaternion(typed.rotation, value.rotation) ? typed.angles : rotationView.toEuler(value.rotation)).map((angle) => angle * DEGREES);
|
|
1169
|
+
const angleLabels = rotationView.labels ?? DEFAULT_ANGLE_LABELS;
|
|
1170
|
+
if (readOnly) {
|
|
1171
|
+
const join = (values, precision) => values.map((component) => formatNumber(component, precision)).join(" ");
|
|
1172
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1173
|
+
role: "group",
|
|
1174
|
+
"aria-label": ariaLabel,
|
|
1175
|
+
"data-readonly": "",
|
|
1176
|
+
className: cn("min-w-0", className),
|
|
1177
|
+
children: /* @__PURE__ */ jsx(ReadoutStrip, { items: [{
|
|
1178
|
+
label: "t",
|
|
1179
|
+
value: `${join(translation, tPrecision)} ${translationUnit}`
|
|
1180
|
+
}, {
|
|
1181
|
+
label: "rpy",
|
|
1182
|
+
value: `${join(degrees, rotationPrecision)} °`
|
|
1183
|
+
}] })
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
const onTranslation = (next) => {
|
|
1187
|
+
const [x = 0, y = 0, z = 0] = next.map((component) => component / scale);
|
|
1188
|
+
onValueChange?.({
|
|
1189
|
+
rotation: value.rotation,
|
|
1190
|
+
translation: [
|
|
1191
|
+
x,
|
|
1192
|
+
y,
|
|
1193
|
+
z
|
|
1194
|
+
]
|
|
1195
|
+
});
|
|
1196
|
+
};
|
|
1197
|
+
const onRotation = (next) => {
|
|
1198
|
+
const [a = 0, b = 0, c = 0] = next.map((angle) => angle / DEGREES);
|
|
1199
|
+
const angles = [
|
|
1200
|
+
a,
|
|
1201
|
+
b,
|
|
1202
|
+
c
|
|
1203
|
+
];
|
|
1204
|
+
const rotation = rotationView.fromEuler(angles);
|
|
1205
|
+
setTyped({
|
|
1206
|
+
rotation,
|
|
1207
|
+
angles
|
|
1208
|
+
});
|
|
1209
|
+
onValueChange?.({
|
|
1210
|
+
rotation,
|
|
1211
|
+
translation: value.translation
|
|
1212
|
+
});
|
|
1213
|
+
};
|
|
1214
|
+
const rowLabel = cn("text-fg-muted", byDensity(density, "text-xs", "text-[11px]"));
|
|
1215
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1216
|
+
role: "group",
|
|
1217
|
+
"aria-label": ariaLabel,
|
|
1218
|
+
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),
|
|
1219
|
+
children: [
|
|
1220
|
+
/* @__PURE__ */ jsx("span", {
|
|
1221
|
+
"aria-hidden": true,
|
|
1222
|
+
className: rowLabel,
|
|
1223
|
+
children: "Translation"
|
|
1224
|
+
}),
|
|
1225
|
+
/* @__PURE__ */ jsx(VectorInput, {
|
|
1226
|
+
"aria-label": "Translation",
|
|
1227
|
+
value: translation,
|
|
1228
|
+
onValueChange: onTranslation,
|
|
1229
|
+
unit: translationUnit,
|
|
1230
|
+
precision: tPrecision,
|
|
1231
|
+
step: translationUnit === "mm" ? 1 : .001,
|
|
1232
|
+
disabled
|
|
1233
|
+
}),
|
|
1234
|
+
/* @__PURE__ */ jsx("span", {
|
|
1235
|
+
"aria-hidden": true,
|
|
1236
|
+
className: rowLabel,
|
|
1237
|
+
children: "Rotation"
|
|
1238
|
+
}),
|
|
1239
|
+
/* @__PURE__ */ jsx(VectorInput, {
|
|
1240
|
+
"aria-label": "Rotation",
|
|
1241
|
+
value: degrees,
|
|
1242
|
+
onValueChange: onRotation,
|
|
1243
|
+
labels: angleLabels,
|
|
1244
|
+
unit: "°",
|
|
1245
|
+
precision: rotationPrecision,
|
|
1246
|
+
step: 1,
|
|
1247
|
+
disabled
|
|
1248
|
+
})
|
|
1249
|
+
]
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
977
1253
|
//#region src/components/ui/SegmentedControl.tsx
|
|
978
1254
|
/**
|
|
979
1255
|
* A choice among two or three, shown as a strip of segments (native radios in one group, so
|
|
@@ -1282,6 +1558,187 @@ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = fal
|
|
|
1282
1558
|
});
|
|
1283
1559
|
}
|
|
1284
1560
|
//#endregion
|
|
1561
|
+
//#region src/components/ui/toastStore.ts
|
|
1562
|
+
const DEFAULT_DURATION = 5e3;
|
|
1563
|
+
const ERROR_DURATION = 8e3;
|
|
1564
|
+
/**
|
|
1565
|
+
* A new, empty toast store. The package keeps one (behind `toast()` and the default
|
|
1566
|
+
* `<Toaster />`); create another only to isolate a region or a test.
|
|
1567
|
+
*
|
|
1568
|
+
* @returns The store.
|
|
1569
|
+
*/
|
|
1570
|
+
function createToastStore() {
|
|
1571
|
+
let toasts = [];
|
|
1572
|
+
let sequence = 0;
|
|
1573
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1574
|
+
const commit = (next) => {
|
|
1575
|
+
toasts = next;
|
|
1576
|
+
for (const listener of [...listeners]) listener();
|
|
1577
|
+
};
|
|
1578
|
+
return {
|
|
1579
|
+
toast: (options) => {
|
|
1580
|
+
const tone = options.tone ?? "info";
|
|
1581
|
+
sequence += 1;
|
|
1582
|
+
const record = {
|
|
1583
|
+
id: options.id ?? `toast-${sequence}`,
|
|
1584
|
+
title: options.title,
|
|
1585
|
+
description: options.description,
|
|
1586
|
+
tone,
|
|
1587
|
+
duration: options.duration ?? (tone === "error" ? ERROR_DURATION : DEFAULT_DURATION)
|
|
1588
|
+
};
|
|
1589
|
+
const existing = toasts.findIndex((t) => t.id === record.id);
|
|
1590
|
+
commit(existing === -1 ? [...toasts, record] : toasts.map((t, i) => i === existing ? record : t));
|
|
1591
|
+
return record.id;
|
|
1592
|
+
},
|
|
1593
|
+
dismiss: (id) => {
|
|
1594
|
+
if (id === void 0) {
|
|
1595
|
+
if (toasts.length > 0) commit([]);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (toasts.some((t) => t.id === id)) commit(toasts.filter((t) => t.id !== id));
|
|
1599
|
+
},
|
|
1600
|
+
getSnapshot: () => toasts,
|
|
1601
|
+
subscribe: (listener) => {
|
|
1602
|
+
listeners.add(listener);
|
|
1603
|
+
return () => {
|
|
1604
|
+
listeners.delete(listener);
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
/** The store behind `toast()` and a `<Toaster />` given no `store`. */
|
|
1610
|
+
const defaultToastStore = createToastStore();
|
|
1611
|
+
/**
|
|
1612
|
+
* Raise a notification, from anywhere — shown by the mounted `<Toaster />`.
|
|
1613
|
+
*
|
|
1614
|
+
* `toast({ title: "Scenario loaded", tone: "success" })`. Pass `id` to update a toast in
|
|
1615
|
+
* place (a progress message that becomes a result). `toast.dismiss(id)` removes one early,
|
|
1616
|
+
* `toast.dismiss()` all of them.
|
|
1617
|
+
*
|
|
1618
|
+
* @param options - The message, its tone and its duration.
|
|
1619
|
+
* @returns The toast's id.
|
|
1620
|
+
*/
|
|
1621
|
+
const toast = Object.assign((options) => defaultToastStore.toast(options), { dismiss: (id) => defaultToastStore.dismiss(id) });
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/components/ui/Toaster.tsx
|
|
1624
|
+
const TONE = {
|
|
1625
|
+
info: {
|
|
1626
|
+
border: "border-signal/30",
|
|
1627
|
+
icon: "text-signal",
|
|
1628
|
+
Icon: Info
|
|
1629
|
+
},
|
|
1630
|
+
success: {
|
|
1631
|
+
border: "border-normal/30",
|
|
1632
|
+
icon: "text-normal",
|
|
1633
|
+
Icon: CircleCheck
|
|
1634
|
+
},
|
|
1635
|
+
warn: {
|
|
1636
|
+
border: "border-warn/30",
|
|
1637
|
+
icon: "text-warn",
|
|
1638
|
+
Icon: TriangleAlert
|
|
1639
|
+
},
|
|
1640
|
+
error: {
|
|
1641
|
+
border: "border-defect/30",
|
|
1642
|
+
icon: "text-defect",
|
|
1643
|
+
Icon: CircleX
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
const NO_TOASTS = [];
|
|
1647
|
+
/**
|
|
1648
|
+
* The stack of notifications raised with `toast()`. Mount it once, near the root.
|
|
1649
|
+
*
|
|
1650
|
+
* Fixed to the bottom-right corner, newest at the bottom. Each toast dismisses itself after
|
|
1651
|
+
* its `duration`; the countdown pauses while the pointer is over the stack or focus is inside
|
|
1652
|
+
* it. A `section` landmark ("Notifications") holding a polite live region, so new toasts are
|
|
1653
|
+
* announced; an `error` toast is a `role="alert"`, announced at once. Each toast carries
|
|
1654
|
+
* `data-tone` and a "Dismiss" button.
|
|
1655
|
+
*/
|
|
1656
|
+
function Toaster({ store = defaultToastStore, limit = 5, className }) {
|
|
1657
|
+
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot, () => NO_TOASTS);
|
|
1658
|
+
const [hovered, setHovered] = useState(false);
|
|
1659
|
+
const [focused, setFocused] = useState(false);
|
|
1660
|
+
const shown = toasts.slice(-Math.max(1, limit));
|
|
1661
|
+
const onBlur = (event) => {
|
|
1662
|
+
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
|
|
1663
|
+
};
|
|
1664
|
+
return /* @__PURE__ */ jsx("section", {
|
|
1665
|
+
"aria-label": "Notifications",
|
|
1666
|
+
onPointerEnter: () => setHovered(true),
|
|
1667
|
+
onPointerLeave: () => setHovered(false),
|
|
1668
|
+
onFocus: () => setFocused(true),
|
|
1669
|
+
onBlur,
|
|
1670
|
+
className: cn("pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]", className),
|
|
1671
|
+
children: /* @__PURE__ */ jsx("ol", {
|
|
1672
|
+
"aria-live": "polite",
|
|
1673
|
+
className: "flex flex-col gap-2",
|
|
1674
|
+
children: shown.map((record) => /* @__PURE__ */ jsx(ToastItem, {
|
|
1675
|
+
record,
|
|
1676
|
+
paused: hovered || focused,
|
|
1677
|
+
onDismiss: () => store.dismiss(record.id)
|
|
1678
|
+
}, record.id))
|
|
1679
|
+
})
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
function ToastItem({ record, paused, onDismiss }) {
|
|
1683
|
+
const { border, icon, Icon } = TONE[record.tone];
|
|
1684
|
+
const remainingRef = useRef({
|
|
1685
|
+
record,
|
|
1686
|
+
ms: record.duration
|
|
1687
|
+
});
|
|
1688
|
+
const dismissRef = useRef(onDismiss);
|
|
1689
|
+
useEffect(() => {
|
|
1690
|
+
dismissRef.current = onDismiss;
|
|
1691
|
+
});
|
|
1692
|
+
useEffect(() => {
|
|
1693
|
+
if (remainingRef.current.record !== record) remainingRef.current = {
|
|
1694
|
+
record,
|
|
1695
|
+
ms: record.duration
|
|
1696
|
+
};
|
|
1697
|
+
if (paused || !Number.isFinite(remainingRef.current.ms)) return;
|
|
1698
|
+
const started = Date.now();
|
|
1699
|
+
const timer = setTimeout(() => dismissRef.current(), remainingRef.current.ms);
|
|
1700
|
+
return () => {
|
|
1701
|
+
clearTimeout(timer);
|
|
1702
|
+
remainingRef.current.ms = Math.max(0, remainingRef.current.ms - (Date.now() - started));
|
|
1703
|
+
};
|
|
1704
|
+
}, [paused, record]);
|
|
1705
|
+
return /* @__PURE__ */ jsx("li", {
|
|
1706
|
+
className: "pointer-events-auto",
|
|
1707
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
1708
|
+
role: record.tone === "error" ? "alert" : void 0,
|
|
1709
|
+
"data-tone": record.tone,
|
|
1710
|
+
className: cn("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", "transition-[opacity,translate] duration-200 starting:translate-y-2 starting:opacity-0", border),
|
|
1711
|
+
children: [
|
|
1712
|
+
/* @__PURE__ */ jsx(Icon, {
|
|
1713
|
+
className: cn("mt-0.5 size-4 shrink-0", icon),
|
|
1714
|
+
"aria-hidden": true
|
|
1715
|
+
}),
|
|
1716
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1717
|
+
className: "flex min-w-0 flex-1 flex-col gap-0.5",
|
|
1718
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
1719
|
+
className: "font-medium text-fg",
|
|
1720
|
+
children: record.title
|
|
1721
|
+
}), record.description && /* @__PURE__ */ jsx("p", {
|
|
1722
|
+
className: "text-fg-muted",
|
|
1723
|
+
children: record.description
|
|
1724
|
+
})]
|
|
1725
|
+
}),
|
|
1726
|
+
/* @__PURE__ */ jsx("button", {
|
|
1727
|
+
type: "button",
|
|
1728
|
+
"aria-label": "Dismiss",
|
|
1729
|
+
title: "Dismiss",
|
|
1730
|
+
onClick: onDismiss,
|
|
1731
|
+
className: cn("-mr-1 grid size-6 shrink-0 place-items-center rounded-control text-fg-subtle transition-colors hover:bg-raised hover:text-fg", focusRing),
|
|
1732
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
1733
|
+
className: "size-3.5",
|
|
1734
|
+
"aria-hidden": true
|
|
1735
|
+
})
|
|
1736
|
+
})
|
|
1737
|
+
]
|
|
1738
|
+
})
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
//#endregion
|
|
1285
1742
|
//#region src/components/Tabs.tsx
|
|
1286
1743
|
const NEXT_KEYS = {
|
|
1287
1744
|
ArrowRight: (index, length) => (index + 1) % length,
|
|
@@ -1344,6 +1801,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
|
|
|
1344
1801
|
});
|
|
1345
1802
|
}
|
|
1346
1803
|
//#endregion
|
|
1347
|
-
export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, ToggleChip, Tooltip, TooltipProvider, buttonClasses, byDensity, cn, controlClasses, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toneColor, useControlHeight, useDensity };
|
|
1804
|
+
export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toast, toneColor, useControlHeight, useDensity };
|
|
1348
1805
|
|
|
1349
1806
|
//# sourceMappingURL=index.js.map
|