@tensor_1/react 2.27.0 → 2.29.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 CHANGED
@@ -308,3 +308,49 @@ const toRoute = (p: UixLinkProps) => <NextLink {...p} />;
308
308
  - **`CollapsibleSection`** stays a JS-free `<details>` unless you pass `lazy`, `persistKey` or `openRequest`. `lazy` unmounts a closed body; `lazy="keep"` keeps it after the first open.
309
309
  - **`ConfirmDialog`** focuses the type-to-confirm field, else Cancel when `destructive`. `Popconfirm` is for low-risk, easily undone actions; use `ConfirmDialog` for anything hard to undo.
310
310
  - **`KbdCombo`** renders the non-Apple glyphs on the server and switches to ⌘/⇧/⌥ after mount, so hydration matches. Pass `platform` to pin it.
311
+
312
+ ### Imperative toasts (2.28.0)
313
+
314
+ `toast()` queues a toast from anywhere on the client (a mutation hook, a tRPC error handler); every mounted
315
+ `<Toaster />` renders the queue (UIx HAR-1363; replaces `sonner` in TENSOR and MOTUS).
316
+
317
+ ```tsx
318
+ <Toaster position="bottom-end" limit={3} /> // once, in the app shell
319
+
320
+ toast.success('Saved', { description: 'Change CHG-1042 updated.' });
321
+ toast.error('Could not save', { action: { label: 'Copy details', onClick: copy } }); // stays until dismissed
322
+ toast.warning('Export is taking longer than usual');
323
+ const id = toast.loading('Exporting…'); toast.update(id, { kind: 'success', message: 'Exported' });
324
+ toast.promise(save(), { loading: 'Saving…', success: 'Saved', error: (e) => `Failed: ${message(e)}` });
325
+ toast.undoable('Ticket closed', { onUndo: reopen, onCommit: close }); // TENSOR C2 useUndoableAction
326
+ toast.dismiss(id); // or toast.dismiss() for all
327
+ ```
328
+
329
+ - **Timing:** success, info and default toasts stay 5 s, warnings 8 s; errors and loading toasts stay until dismissed or updated. Pass `duration` (ms, or `null`) to override. Timers pause while the pointer or focus is inside the toaster and while the tab is hidden.
330
+ - **Stacking:** at most `limit` toasts show (default 3). The rest wait their turn. Reusing an `id` replaces a toast in place.
331
+ - **Accessibility:** errors are `role="alert"`. Everything else is announced through the toaster's polite live region, and a loading toast that settles is announced again. Esc closes the focused toast. Tone glyphs are decorative; the text carries the meaning.
332
+ - **Stores:** `toast` writes to a default store that `<Toaster />` reads. `createToastApi(createToastStore())` gives a separate queue (tests, embedded apps); pass its `store` to `<Toaster store>`. The store has no React dependency.
333
+ - `<Toaster>` still renders hand-managed `<Toast>` children first. `Toast` gains `tone="warning"` and an `action` slot.
334
+ ### Icons (`@tensor_1/react/icons`)
335
+
336
+ The UIx icon set (HAR-996): 233 glyphs drawn in `currentColor` at the `--uix-icon-*` sizes. Icons come only from UIx
337
+ (workspace ADR-0038); no product adds an icon library. The glyphs are Lucide's (ISC); the licence travels in
338
+ `THIRD_PARTY_NOTICES.md`. Import the CSS once: `@tensor_1/tokens/components/icon` (also in the bundle).
339
+
340
+ ```tsx
341
+ import { ShieldCheckIcon, TriangleAlertIcon, Icon } from '@tensor_1/react/icons';
342
+ import type { UixIcon, IconName } from '@tensor_1/react/icons';
343
+
344
+ <ShieldCheckIcon /> // decorative: aria-hidden, 20 px (md)
345
+ <ShieldCheckIcon size="sm" tone="success" label="Verified" /> // role="img" with a name
346
+ <Button icon aria-label="Delete"><Trash2Icon /></Button> // the button carries the name
347
+ <Icon name={entityIconName} /> // chosen at run time (loads the whole set)
348
+ const icons: Record<EntityType, UixIcon> = { incident: SirenIcon, change: GitBranchIcon };
349
+ ```
350
+
351
+ - **Migrating from `lucide-react`:** import from `@tensor_1/react/icons` and add `Icon` to the name: `ShieldCheck` → `ShieldCheckIcon`. The older Lucide names the products use are kept as aliases (`AlertTriangleIcon`, `CheckCircle2Icon`, `Loader2Icon`, …). `etc/icon-names.json` maps every Lucide name to its UIx component and glyph, for a codemod. `LucideIcon` becomes `UixIcon`.
352
+ - **Size:** `sm` 16 px, `md` 20 px (default), `lg` 24 px, or any CSS length or pixel number. A product sizing class (a Tailwind `size-4`) wins over the default.
353
+ - **Tone:** `current` (default, inherits the text colour), `muted`, `accent`, `success`, `warning`, `danger`, `info`.
354
+ - **Accessibility:** decorative unless `label` is set. Never let an icon carry meaning alone; pair it with text.
355
+ - **Bundle size:** each `<Name>Icon` is tree-shaken; one icon adds well under 3 KB minified. `<Icon name>` and `ICON_GLYPHS` load all of them.
356
+ - **A glyph is missing?** Add its Lucide name to `scripts/icon-names.txt`, run `node scripts/generate-icons.mjs <lucide-react dir>` and release. Do not paste an SVG into a product.
@@ -0,0 +1,54 @@
1
+ # Third-party notices
2
+
3
+ `@tensor_1/react` redistributes SVG path data from these projects. Their licences require this notice to travel with copies.
4
+
5
+ ## Lucide (lucide-react 1.39.0)
6
+
7
+ Used by: the icon set (`@tensor_1/react/icons`, `dist/icons/**`) and the editor, emoji, reaction and table-control glyphs (`EditorIcons`). Source: https://lucide.dev
8
+
9
+ ```text
10
+ ISC License
11
+
12
+ Copyright (c) 2026 Lucide Icons and Contributors
13
+
14
+ Permission to use, copy, modify, and/or distribute this software for any
15
+ purpose with or without fee is hereby granted, provided that the above
16
+ copyright notice and this permission notice appear in all copies.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
19
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
20
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
21
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
22
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
23
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
24
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
25
+
26
+ ---
27
+
28
+ The following Lucide icons are derived from the Feather project:
29
+
30
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
31
+
32
+ The MIT License (MIT) (for the icons listed above)
33
+
34
+ Copyright (c) 2013-present Cole Bemis
35
+
36
+ Permission is hereby granted, free of charge, to any person obtaining a copy
37
+ of this software and associated documentation files (the "Software"), to deal
38
+ in the Software without restriction, including without limitation the rights
39
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
40
+ copies of the Software, and to permit persons to whom the Software is
41
+ furnished to do so, subject to the following conditions:
42
+
43
+ The above copyright notice and this permission notice shall be included in all
44
+ copies or substantial portions of the Software.
45
+
46
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
47
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
48
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
49
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
50
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
51
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
52
+ SOFTWARE.
53
+
54
+ ```
@@ -1,7 +1,8 @@
1
1
  import * as react from 'react';
2
2
  import { HTMLAttributes, ReactNode } from 'react';
3
+ import { ToastStore } from '../toast-store.js';
3
4
 
4
- type ToastTone = 'success' | 'danger' | 'info';
5
+ type ToastTone = 'success' | 'danger' | 'info' | 'warning';
5
6
  interface ToastProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
6
7
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
7
8
  dismissLabel?: string;
@@ -9,15 +10,26 @@ interface ToastProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
9
10
  message?: ReactNode;
10
11
  tone?: ToastTone;
11
12
  icon?: ReactNode;
13
+ /** A button (or link) after the text, e.g. "Undo" or "Copy details" (HAR-1363). */
14
+ action?: ReactNode;
12
15
  onClose?: () => void;
13
16
  leaving?: boolean;
14
17
  }
15
- declare function Toast({ title, message, tone, icon, onClose, leaving, dismissLabel: dismissLabelProp, className, ...props }: ToastProps): react.JSX.Element;
18
+ declare function Toast({ title, message, tone, icon, action, onClose, leaving, dismissLabel: dismissLabelProp, className, ...props }: ToastProps): react.JSX.Element;
16
19
  interface ToasterProps extends HTMLAttributes<HTMLDivElement> {
17
20
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
18
21
  regionLabel?: string;
22
+ /**
23
+ * The queue to render (HAR-1363). Default: the store behind the exported `toast()`.
24
+ * Children still render first, so a hand-managed `<Toast>` keeps working.
25
+ */
26
+ store?: ToastStore;
27
+ /** At most this many store toasts on screen; later ones wait their turn. Default 3. */
28
+ limit?: number;
29
+ /** Corner of the viewport. Default `bottom-end`. */
30
+ position?: 'bottom-end' | 'bottom-start' | 'bottom-center' | 'top-end' | 'top-start' | 'top-center';
19
31
  children?: ReactNode;
20
32
  }
21
- declare function Toaster({ children, regionLabel: regionLabelProp, className, ...props }: ToasterProps): react.JSX.Element;
33
+ declare function Toaster({ children, regionLabel: regionLabelProp, store, limit, position, className, onMouseEnter, onMouseLeave, onFocus, onBlur, onKeyDown, ...props }: ToasterProps): react.JSX.Element;
22
34
 
23
35
  export { Toast, type ToastProps, type ToastTone, Toaster, type ToasterProps };
@@ -1,10 +1,11 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useUixLabels } from "../labels-context.js";
6
+ import { toast as defaultToast } from "../toast-store.js";
6
7
  const ToasterContext = createContext(null);
7
- function Toast({ title, message, tone, icon, onClose, leaving, dismissLabel: dismissLabelProp, className, ...props }) {
8
+ function Toast({ title, message, tone, icon, action, onClose, leaving, dismissLabel: dismissLabelProp, className, ...props }) {
8
9
  const uixLabels = useUixLabels();
9
10
  const dismissLabel = dismissLabelProp ?? uixLabels.toast?.dismiss ?? "Dismiss";
10
11
  const assertive = tone === "danger";
@@ -42,12 +43,106 @@ function Toast({ title, message, tone, icon, onClose, leaving, dismissLabel: dis
42
43
  title && /* @__PURE__ */ jsx("div", { className: "uix-toast__title", children: title }),
43
44
  message && /* @__PURE__ */ jsx("div", { className: "uix-toast__msg", children: message })
44
45
  ] }),
45
- onClose && /* @__PURE__ */ jsx("button", { className: "uix-toast__close", onClick: handleClose, "aria-label": dismissLabel, children: "\xD7" })
46
+ action != null && /* @__PURE__ */ jsx("div", { className: "uix-toast__action-slot", children: action }),
47
+ onClose && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-toast__close", onClick: handleClose, "aria-label": dismissLabel, children: "\xD7" })
46
48
  ]
47
49
  }
48
50
  );
49
51
  }
50
- function Toaster({ children, regionLabel: regionLabelProp, className, ...props }) {
52
+ const KIND_TONE = {
53
+ success: "success",
54
+ error: "danger",
55
+ warning: "warning",
56
+ info: "info",
57
+ loading: "info",
58
+ default: void 0
59
+ };
60
+ const svg = (path) => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", children: path });
61
+ const KIND_GLYPH = {
62
+ success: svg(/* @__PURE__ */ jsxs(Fragment, { children: [
63
+ /* @__PURE__ */ jsx("circle", { cx: "10", cy: "10", r: "7.25" }),
64
+ /* @__PURE__ */ jsx("path", { d: "M6.75 10.25l2.25 2.25 4.25-4.75" })
65
+ ] })),
66
+ error: svg(/* @__PURE__ */ jsxs(Fragment, { children: [
67
+ /* @__PURE__ */ jsx("circle", { cx: "10", cy: "10", r: "7.25" }),
68
+ /* @__PURE__ */ jsx("path", { d: "M10 6.25v4.5M10 13.5v.25" })
69
+ ] })),
70
+ warning: svg(/* @__PURE__ */ jsxs(Fragment, { children: [
71
+ /* @__PURE__ */ jsx("path", { d: "M10 3.25l7.25 13H2.75z" }),
72
+ /* @__PURE__ */ jsx("path", { d: "M10 8.25v3.5M10 14v.25" })
73
+ ] })),
74
+ info: svg(/* @__PURE__ */ jsxs(Fragment, { children: [
75
+ /* @__PURE__ */ jsx("circle", { cx: "10", cy: "10", r: "7.25" }),
76
+ /* @__PURE__ */ jsx("path", { d: "M10 9.25v4.5M10 6.5v.25" })
77
+ ] })),
78
+ loading: /* @__PURE__ */ jsx("span", { className: "uix-spinner uix-toast__spinner" })
79
+ };
80
+ function StoreToast({ record, store }) {
81
+ const action = record.action && /* @__PURE__ */ jsx(
82
+ "button",
83
+ {
84
+ type: "button",
85
+ className: "uix-toast__action",
86
+ onClick: () => {
87
+ record.action.onClick();
88
+ store.dismiss(record.id, "action");
89
+ },
90
+ children: record.action.label
91
+ }
92
+ );
93
+ return /* @__PURE__ */ jsx(
94
+ Toast,
95
+ {
96
+ "data-toast-id": record.id,
97
+ tone: KIND_TONE[record.kind],
98
+ icon: record.icon ?? KIND_GLYPH[record.kind],
99
+ title: record.message,
100
+ message: record.description,
101
+ action,
102
+ leaving: record.leaving,
103
+ "aria-busy": record.kind === "loading" || void 0,
104
+ onClose: () => store.dismiss(record.id, "close")
105
+ }
106
+ );
107
+ }
108
+ function useToastTimers(store, visible, paused) {
109
+ const timers = useRef(/* @__PURE__ */ new Map());
110
+ useEffect(() => {
111
+ const now = Date.now();
112
+ const map = timers.current;
113
+ const live = new Map(visible.filter((r) => !r.leaving && r.duration != null).map((r) => [r.id, r]));
114
+ for (const [id, timer] of map) {
115
+ const record = live.get(id);
116
+ if (!record || record.version !== timer.version) {
117
+ if (timer.handle != null) clearTimeout(timer.handle);
118
+ map.delete(id);
119
+ }
120
+ }
121
+ for (const record of live.values()) {
122
+ let timer = map.get(record.id);
123
+ if (!timer) {
124
+ timer = { version: record.version, remaining: record.duration, start: now };
125
+ map.set(record.id, timer);
126
+ }
127
+ if (paused) {
128
+ if (timer.handle != null) {
129
+ clearTimeout(timer.handle);
130
+ timer.handle = void 0;
131
+ timer.remaining -= now - timer.start;
132
+ }
133
+ } else if (timer.handle == null) {
134
+ timer.start = now;
135
+ const id = record.id;
136
+ timer.handle = window.setTimeout(() => store.dismiss(id, "timeout"), Math.max(0, timer.remaining));
137
+ }
138
+ }
139
+ });
140
+ useEffect(() => () => {
141
+ for (const timer of timers.current.values()) if (timer.handle != null) clearTimeout(timer.handle);
142
+ timers.current.clear();
143
+ }, []);
144
+ }
145
+ function Toaster({ children, regionLabel: regionLabelProp, store = defaultToast.store, limit = 3, position = "bottom-end", className, onMouseEnter, onMouseLeave, onFocus, onBlur, onKeyDown, ...props }) {
51
146
  const uixLabels = useUixLabels();
52
147
  const regionLabel = regionLabelProp ?? uixLabels.toaster?.region ?? "Notifications";
53
148
  const [announced, setAnnounced] = useState("");
@@ -61,10 +156,59 @@ function Toaster({ children, regionLabel: regionLabelProp, className, ...props }
61
156
  if (clearTimer.current != null) clearTimeout(clearTimer.current);
62
157
  }, []);
63
158
  const ctx = useMemo(() => ({ announce }), [announce]);
64
- return /* @__PURE__ */ jsx(ToasterContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxs("div", { className: cx("uix-toaster", className), role: "region", "aria-label": regionLabel, ...props, children: [
65
- /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: announced }),
66
- children
67
- ] }) });
159
+ const records = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
160
+ const visible = useMemo(() => records.slice(-Math.max(1, limit)), [records, limit]);
161
+ const [hovered, setHovered] = useState(false);
162
+ const [focused, setFocused] = useState(false);
163
+ const [hidden, setHidden] = useState(false);
164
+ useEffect(() => {
165
+ const sync = () => setHidden(document.visibilityState === "hidden");
166
+ sync();
167
+ document.addEventListener("visibilitychange", sync);
168
+ return () => document.removeEventListener("visibilitychange", sync);
169
+ }, []);
170
+ useToastTimers(store, visible, hovered || focused || hidden);
171
+ const handleKeyDown = (event) => {
172
+ onKeyDown?.(event);
173
+ if (event.defaultPrevented || event.key !== "Escape") return;
174
+ const id = event.target.closest("[data-toast-id]")?.dataset.toastId;
175
+ if (!id) return;
176
+ event.preventDefault();
177
+ event.stopPropagation();
178
+ store.dismiss(id, "close");
179
+ };
180
+ return /* @__PURE__ */ jsx(ToasterContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxs(
181
+ "div",
182
+ {
183
+ className: cx("uix-toaster", className),
184
+ role: "region",
185
+ "aria-label": regionLabel,
186
+ "data-position": position === "bottom-end" ? void 0 : position,
187
+ onMouseEnter: (e) => {
188
+ setHovered(true);
189
+ onMouseEnter?.(e);
190
+ },
191
+ onMouseLeave: (e) => {
192
+ setHovered(false);
193
+ onMouseLeave?.(e);
194
+ },
195
+ onFocus: (e) => {
196
+ setFocused(true);
197
+ onFocus?.(e);
198
+ },
199
+ onBlur: (e) => {
200
+ if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);
201
+ onBlur?.(e);
202
+ },
203
+ onKeyDown: handleKeyDown,
204
+ ...props,
205
+ children: [
206
+ /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: announced }),
207
+ children,
208
+ visible.map((record) => /* @__PURE__ */ jsx(StoreToast, { record, store }, `${record.id}:${record.kind}`))
209
+ ]
210
+ }
211
+ ) });
68
212
  }
69
213
  export {
70
214
  Toast,