@tensor_1/react 2.3.0 → 2.4.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.
@@ -0,0 +1,61 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface NavFavouriteItem {
5
+ /** Stable identity used for reorder + remove (consumers typically pass the href). */
6
+ readonly id: string;
7
+ /** Display label, interpolated into the per-row remove / options accessible names. */
8
+ readonly label: string;
9
+ /** The destination control, rendered by the consumer so routing + link styling stay app-owned. */
10
+ readonly link: ReactNode;
11
+ }
12
+ interface NavFavouritesLabels {
13
+ /** Disclosure header, e.g. "Favourites". */
14
+ readonly heading: string;
15
+ /** Shown when there are no favourites. */
16
+ readonly empty: string;
17
+ readonly moveUp: string;
18
+ readonly moveDown: string;
19
+ /** Per-row remove action template with a `{label}` placeholder, e.g. "Remove {label} from favourites". */
20
+ readonly remove: string;
21
+ /** Per-row overflow-trigger accessible-name template with `{label}`. Defaults to "{label} favourite options". */
22
+ readonly options?: string;
23
+ }
24
+ interface NavFavouritesProps {
25
+ /** The already-resolved favourites, in display order. */
26
+ items: ReadonlyArray<NavFavouriteItem>;
27
+ collapsed?: boolean;
28
+ onToggleCollapsed?: () => void;
29
+ /** Called with the id of the favourite to unpin. */
30
+ onRemove: (id: string) => void;
31
+ /** Called with the full new id order after a move. */
32
+ onReorder: (orderedIds: string[]) => void;
33
+ labels: NavFavouritesLabels;
34
+ /** Optional telemetry hook emitted as `data-empty-reason` on the empty-state node. */
35
+ emptyReason?: string;
36
+ className?: string;
37
+ }
38
+ /**
39
+ * Controlled Favourites disclosure for a sidebar nav (UIx owns the look + the
40
+ * APG interaction contract; the consumer owns the favourites list + persistence).
41
+ *
42
+ * An ARIA disclosure exactly like a collapsible nav group: a header button with
43
+ * `aria-expanded`/`aria-controls`, the region always rendered with a stable id
44
+ * and toggled via `hidden`, focus pulled to the header before collapse.
45
+ *
46
+ * Each row carries the consumer-rendered destination link plus:
47
+ * - an overflow menu (APG menu-button: button + `aria-haspopup="menu"` +
48
+ * `aria-expanded`; Up/Down roving focus; Escape returns focus to the
49
+ * trigger) with Move up / Move down / Remove,
50
+ * - Alt+ArrowUp / Alt+ArrowDown accelerators on the focused row so moves
51
+ * chain without re-opening the menu.
52
+ *
53
+ * Focus management:
54
+ * - after Remove, focus moves to the adjacent favourite, or to the header
55
+ * when the list empties,
56
+ * - after Move up/down, focus stays on the moved row's control in its new
57
+ * position.
58
+ */
59
+ declare function NavFavourites({ items, collapsed, onToggleCollapsed, onRemove, onReorder, labels, emptyReason, className, }: NavFavouritesProps): react.JSX.Element;
60
+
61
+ export { type NavFavouriteItem, NavFavourites, type NavFavouritesLabels, type NavFavouritesProps };
@@ -0,0 +1,352 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { cx } from "../cx.js";
5
+ const ChevronDownIcon = () => /* @__PURE__ */ jsx(
6
+ "svg",
7
+ {
8
+ width: "12",
9
+ height: "12",
10
+ viewBox: "0 0 24 24",
11
+ fill: "none",
12
+ stroke: "currentColor",
13
+ strokeWidth: 2,
14
+ strokeLinecap: "round",
15
+ strokeLinejoin: "round",
16
+ "aria-hidden": "true",
17
+ style: { flexShrink: 0 },
18
+ children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
19
+ }
20
+ );
21
+ const ChevronRightIcon = () => /* @__PURE__ */ jsx(
22
+ "svg",
23
+ {
24
+ width: "12",
25
+ height: "12",
26
+ viewBox: "0 0 24 24",
27
+ fill: "none",
28
+ stroke: "currentColor",
29
+ strokeWidth: 2,
30
+ strokeLinecap: "round",
31
+ strokeLinejoin: "round",
32
+ "aria-hidden": "true",
33
+ style: { flexShrink: 0 },
34
+ children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
35
+ }
36
+ );
37
+ const MoreIcon = () => /* @__PURE__ */ jsxs(
38
+ "svg",
39
+ {
40
+ width: "14",
41
+ height: "14",
42
+ viewBox: "0 0 24 24",
43
+ fill: "none",
44
+ stroke: "currentColor",
45
+ strokeWidth: 1.75,
46
+ strokeLinecap: "round",
47
+ strokeLinejoin: "round",
48
+ "aria-hidden": "true",
49
+ children: [
50
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "1" }),
51
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "5", r: "1" }),
52
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "19", r: "1" })
53
+ ]
54
+ }
55
+ );
56
+ const MUTED = "color-mix(in srgb, var(--uix-text) 50%, transparent)";
57
+ function NavFavourites({
58
+ items,
59
+ collapsed = false,
60
+ onToggleCollapsed,
61
+ onRemove,
62
+ onReorder,
63
+ labels,
64
+ emptyReason,
65
+ className
66
+ }) {
67
+ const regionId = useId();
68
+ const headerRef = useRef(null);
69
+ const regionRef = useRef(null);
70
+ const focusAfterRef = useRef(null);
71
+ const triggerRefs = useRef(/* @__PURE__ */ new Map());
72
+ const order = items.map((i) => i.id);
73
+ useEffect(() => {
74
+ const id = focusAfterRef.current;
75
+ if (!id) return;
76
+ focusAfterRef.current = null;
77
+ const el = triggerRefs.current.get(id);
78
+ if (el) el.focus();
79
+ else headerRef.current?.focus();
80
+ }, [order.join("|")]);
81
+ const handleToggle = () => {
82
+ if (!collapsed) {
83
+ const active = document.activeElement;
84
+ if (active && regionRef.current?.contains(active) && active !== headerRef.current) {
85
+ headerRef.current?.focus();
86
+ }
87
+ }
88
+ onToggleCollapsed?.();
89
+ };
90
+ const move = (id, dir) => {
91
+ const idx = order.indexOf(id);
92
+ const next = idx + dir;
93
+ if (idx < 0 || next < 0 || next >= order.length) return;
94
+ const reordered = [...order];
95
+ const [it] = reordered.splice(idx, 1);
96
+ if (it === void 0) return;
97
+ reordered.splice(next, 0, it);
98
+ focusAfterRef.current = id;
99
+ onReorder(reordered);
100
+ };
101
+ const remove = (id) => {
102
+ const idx = order.indexOf(id);
103
+ const neighbour = order[idx + 1] ?? order[idx - 1] ?? null;
104
+ focusAfterRef.current = neighbour;
105
+ onRemove(id);
106
+ };
107
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-favourites", className), style: { marginBottom: "var(--uix-space-1)" }, children: [
108
+ /* @__PURE__ */ jsxs(
109
+ "button",
110
+ {
111
+ ref: headerRef,
112
+ type: "button",
113
+ "aria-expanded": !collapsed,
114
+ "aria-controls": regionId,
115
+ onClick: handleToggle,
116
+ className: "uix-text-eyebrow",
117
+ style: {
118
+ display: "flex",
119
+ width: "100%",
120
+ alignItems: "center",
121
+ gap: "6px",
122
+ padding: "8px 4px 4px",
123
+ border: 0,
124
+ background: "transparent",
125
+ cursor: "pointer",
126
+ color: "inherit"
127
+ },
128
+ children: [
129
+ collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {}) : /* @__PURE__ */ jsx(ChevronDownIcon, {}),
130
+ labels.heading
131
+ ]
132
+ }
133
+ ),
134
+ /* @__PURE__ */ jsx(
135
+ "ul",
136
+ {
137
+ ref: regionRef,
138
+ id: regionId,
139
+ hidden: collapsed,
140
+ style: {
141
+ display: "flex",
142
+ flexDirection: "column",
143
+ gap: "1px",
144
+ listStyle: "none",
145
+ margin: 0,
146
+ padding: 0
147
+ },
148
+ children: items.length === 0 ? /* @__PURE__ */ jsx(
149
+ "li",
150
+ {
151
+ className: "uix-text-meta",
152
+ "data-empty-reason": emptyReason,
153
+ style: { padding: "6px 8px", color: MUTED },
154
+ children: labels.empty
155
+ }
156
+ ) : items.map((item, idx) => /* @__PURE__ */ jsx(
157
+ FavouriteRow,
158
+ {
159
+ item,
160
+ isFirst: idx === 0,
161
+ isLast: idx === items.length - 1,
162
+ labels,
163
+ onMoveUp: () => move(item.id, -1),
164
+ onMoveDown: () => move(item.id, 1),
165
+ onRemove: () => remove(item.id),
166
+ registerTrigger: (el) => triggerRefs.current.set(item.id, el)
167
+ },
168
+ item.id
169
+ ))
170
+ }
171
+ )
172
+ ] });
173
+ }
174
+ function FavouriteRow({
175
+ item,
176
+ isFirst,
177
+ isLast,
178
+ labels,
179
+ onMoveUp,
180
+ onMoveDown,
181
+ onRemove,
182
+ registerTrigger
183
+ }) {
184
+ const [open, setOpen] = useState(false);
185
+ const [revealed, setRevealed] = useState(false);
186
+ const menuId = useId();
187
+ const triggerRef = useRef(null);
188
+ const itemRefs = useRef([]);
189
+ const optionsTemplate = labels.options ?? "{label} favourite options";
190
+ const actions = [
191
+ ...isFirst ? [] : [{ label: labels.moveUp, onSelect: onMoveUp }],
192
+ ...isLast ? [] : [{ label: labels.moveDown, onSelect: onMoveDown }],
193
+ { label: labels.remove.replace("{label}", item.label), onSelect: onRemove }
194
+ ];
195
+ useEffect(() => {
196
+ if (open) itemRefs.current[0]?.focus();
197
+ }, [open]);
198
+ const closeAndReturnFocus = () => {
199
+ setOpen(false);
200
+ triggerRef.current?.focus();
201
+ };
202
+ const onMenuKeyDown = (e) => {
203
+ const menuItems = itemRefs.current.filter((x) => x !== null);
204
+ const currentIdx = menuItems.findIndex((el) => el === document.activeElement);
205
+ if (e.key === "ArrowDown") {
206
+ e.preventDefault();
207
+ menuItems[(currentIdx + 1) % menuItems.length]?.focus();
208
+ } else if (e.key === "ArrowUp") {
209
+ e.preventDefault();
210
+ menuItems[(currentIdx - 1 + menuItems.length) % menuItems.length]?.focus();
211
+ } else if (e.key === "Escape") {
212
+ e.preventDefault();
213
+ closeAndReturnFocus();
214
+ } else if (e.key === "Tab") {
215
+ setOpen(false);
216
+ }
217
+ };
218
+ const onRowKeyDown = (e) => {
219
+ if (!e.altKey) return;
220
+ if (e.key === "ArrowUp" && !isFirst) {
221
+ e.preventDefault();
222
+ onMoveUp();
223
+ } else if (e.key === "ArrowDown" && !isLast) {
224
+ e.preventDefault();
225
+ onMoveDown();
226
+ }
227
+ };
228
+ const triggerVisible = revealed || open;
229
+ return (
230
+ // biome-ignore lint/a11y/noStaticElementInteractions: keyboard accelerators on the row; the focusable controls inside carry their own semantics.
231
+ /* @__PURE__ */ jsxs(
232
+ "li",
233
+ {
234
+ style: { position: "relative", display: "flex", alignItems: "center" },
235
+ onKeyDown: onRowKeyDown,
236
+ onMouseEnter: () => setRevealed(true),
237
+ onMouseLeave: () => setRevealed(false),
238
+ onFocus: () => setRevealed(true),
239
+ onBlur: (e) => {
240
+ if (!e.currentTarget.contains(e.relatedTarget)) setRevealed(false);
241
+ },
242
+ children: [
243
+ /* @__PURE__ */ jsx("div", { style: { minWidth: 0, flex: 1 }, children: item.link }),
244
+ /* @__PURE__ */ jsx(
245
+ "button",
246
+ {
247
+ ref: (el) => {
248
+ triggerRef.current = el;
249
+ registerTrigger(el);
250
+ },
251
+ type: "button",
252
+ "aria-haspopup": "menu",
253
+ "aria-expanded": open,
254
+ "aria-controls": open ? menuId : void 0,
255
+ "aria-label": optionsTemplate.replace("{label}", item.label),
256
+ onClick: () => setOpen((v) => !v),
257
+ style: {
258
+ marginLeft: "auto",
259
+ display: "inline-flex",
260
+ height: "20px",
261
+ width: "20px",
262
+ flexShrink: 0,
263
+ alignItems: "center",
264
+ justifyContent: "center",
265
+ border: 0,
266
+ borderRadius: "2px",
267
+ background: "transparent",
268
+ cursor: "pointer",
269
+ color: MUTED,
270
+ opacity: triggerVisible ? 1 : 0,
271
+ transition: "opacity var(--uix-dur-fast) var(--uix-ease-out)"
272
+ },
273
+ children: /* @__PURE__ */ jsx(MoreIcon, {})
274
+ }
275
+ ),
276
+ open ? /* @__PURE__ */ jsxs(Fragment, { children: [
277
+ /* @__PURE__ */ jsx(
278
+ "div",
279
+ {
280
+ "aria-hidden": "true",
281
+ role: "presentation",
282
+ onClick: () => setOpen(false),
283
+ style: { position: "fixed", inset: 0, cursor: "default", background: "transparent", zIndex: 40 }
284
+ }
285
+ ),
286
+ /* @__PURE__ */ jsx(
287
+ "ul",
288
+ {
289
+ id: menuId,
290
+ role: "menu",
291
+ onKeyDown: onMenuKeyDown,
292
+ style: {
293
+ position: "absolute",
294
+ right: 0,
295
+ top: "calc(100% + 2px)",
296
+ zIndex: 50,
297
+ minWidth: "160px",
298
+ overflow: "hidden",
299
+ margin: 0,
300
+ padding: "4px 0",
301
+ listStyle: "none",
302
+ borderRadius: "6px",
303
+ border: "1px solid var(--uix-border)",
304
+ background: "var(--uix-surface)",
305
+ boxShadow: "var(--uix-shadow-popover)"
306
+ },
307
+ children: actions.map((action, i) => /* @__PURE__ */ jsx("li", { role: "presentation", children: /* @__PURE__ */ jsx(
308
+ "button",
309
+ {
310
+ ref: (el) => {
311
+ itemRefs.current[i] = el;
312
+ },
313
+ type: "button",
314
+ role: "menuitem",
315
+ tabIndex: -1,
316
+ onClick: () => {
317
+ setOpen(false);
318
+ action.onSelect();
319
+ },
320
+ style: {
321
+ display: "flex",
322
+ width: "100%",
323
+ alignItems: "center",
324
+ padding: "6px 12px",
325
+ border: 0,
326
+ background: "transparent",
327
+ cursor: "pointer",
328
+ textAlign: "left",
329
+ fontSize: "13px",
330
+ color: "color-mix(in srgb, var(--uix-text) 85%, transparent)",
331
+ transition: "background-color var(--uix-dur-fast) var(--uix-ease-out)"
332
+ },
333
+ onMouseEnter: (e) => {
334
+ e.currentTarget.style.background = "var(--uix-bg-hover)";
335
+ },
336
+ onMouseLeave: (e) => {
337
+ e.currentTarget.style.background = "transparent";
338
+ },
339
+ children: action.label
340
+ }
341
+ ) }, action.label))
342
+ }
343
+ )
344
+ ] }) : null
345
+ ]
346
+ }
347
+ )
348
+ );
349
+ }
350
+ export {
351
+ NavFavourites
352
+ };
@@ -0,0 +1,31 @@
1
+ import * as react from 'react';
2
+
3
+ interface StarButtonProps {
4
+ /** Pinned state — drives the filled, warning-toned, always-visible look via `data-on`. */
5
+ pinned: boolean;
6
+ /** Item label, interpolated into the accessible name. */
7
+ label: string;
8
+ /**
9
+ * Accessible-name template carrying a `{label}` placeholder. ONE state model:
10
+ * a STATIC name ("Add {label} to favourites") plus `aria-pressed` reflecting
11
+ * the pinned state — never a dynamic Pin/Remove name AND `aria-pressed` (that
12
+ * double-encodes the state). Defaults to `Add {label} to favourites`.
13
+ */
14
+ addLabel?: string;
15
+ /** Called with the next pinned state when the control is activated. */
16
+ onToggle: (next: boolean) => void;
17
+ /** Extra classes — e.g. consumer-owned reveal triggers when the row is not a `.uix-navitem`. */
18
+ className?: string;
19
+ }
20
+ /**
21
+ * Controlled pin/unpin star for a nav item. UIx owns the look (the
22
+ * `.uix-navitem__star` contract: reveal-on-hover inside a `.uix-navitem`,
23
+ * warning-toned fill when pinned); the consumer owns the pinned state.
24
+ *
25
+ * Reveal-on-hover is opacity-only (zeroed under `prefers-reduced-motion`).
26
+ * When the star lives outside a `.uix-navitem` row, pass reveal triggers via
27
+ * `className`.
28
+ */
29
+ declare function StarButton({ pinned, label, addLabel, onToggle, className, }: StarButtonProps): react.JSX.Element;
30
+
31
+ export { StarButton, type StarButtonProps };
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { cx } from "../cx.js";
4
+ const StarGlyph = () => /* @__PURE__ */ jsx(
5
+ "svg",
6
+ {
7
+ viewBox: "0 0 24 24",
8
+ fill: "none",
9
+ stroke: "currentColor",
10
+ strokeWidth: 2,
11
+ strokeLinecap: "round",
12
+ strokeLinejoin: "round",
13
+ "aria-hidden": "true",
14
+ children: /* @__PURE__ */ jsx("path", { d: "M11.5 2.7a.6.6 0 0 1 1 0l2.4 5 5.4.8a.6.6 0 0 1 .3 1l-3.9 3.8.9 5.4a.6.6 0 0 1-.8.6L12 17l-4.8 2.5a.6.6 0 0 1-.8-.6l.9-5.4L3.4 9.5a.6.6 0 0 1 .3-1l5.4-.8z" })
15
+ }
16
+ );
17
+ function StarButton({
18
+ pinned,
19
+ label,
20
+ addLabel = "Add {label} to favourites",
21
+ onToggle,
22
+ className
23
+ }) {
24
+ return /* @__PURE__ */ jsx(
25
+ "button",
26
+ {
27
+ type: "button",
28
+ className: cx("uix-navitem__star", className),
29
+ "data-on": pinned || void 0,
30
+ "aria-pressed": pinned,
31
+ "aria-label": addLabel.replace("{label}", label),
32
+ onClick: (e) => {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ onToggle(!pinned);
36
+ },
37
+ children: /* @__PURE__ */ jsx(StarGlyph, {})
38
+ }
39
+ );
40
+ }
41
+ export {
42
+ StarButton
43
+ };