@tensor_1/react 2.9.0 → 2.10.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.
Files changed (66) hide show
  1. package/dist/chart.cjs +12 -3
  2. package/dist/components/Alert.js +15 -7
  3. package/dist/components/AppShell.d.ts +3 -1
  4. package/dist/components/AppShell.js +3 -1
  5. package/dist/components/Avatar.js +2 -1
  6. package/dist/components/Button.js +32 -16
  7. package/dist/components/Card.d.ts +3 -1
  8. package/dist/components/Card.js +2 -2
  9. package/dist/components/Chart.js +12 -3
  10. package/dist/components/CommandPalette.d.ts +4 -2
  11. package/dist/components/CommandPalette.js +66 -13
  12. package/dist/components/Comments.d.ts +5 -1
  13. package/dist/components/Comments.js +1 -1
  14. package/dist/components/Composer.d.ts +8 -1
  15. package/dist/components/Composer.js +7 -2
  16. package/dist/components/DetailLayout.js +1 -1
  17. package/dist/components/Drawer.d.ts +3 -4
  18. package/dist/components/Drawer.js +6 -4
  19. package/dist/components/Field.js +6 -3
  20. package/dist/components/Inbox.d.ts +13 -3
  21. package/dist/components/Inbox.js +61 -3
  22. package/dist/components/Input.d.ts +13 -2
  23. package/dist/components/Input.js +20 -5
  24. package/dist/components/Kanban.d.ts +15 -3
  25. package/dist/components/Kanban.js +42 -10
  26. package/dist/components/Meter.d.ts +3 -1
  27. package/dist/components/Meter.js +9 -1
  28. package/dist/components/Modal.d.ts +3 -5
  29. package/dist/components/Modal.js +6 -4
  30. package/dist/components/NavFavourites.js +11 -0
  31. package/dist/components/PageHeader.d.ts +3 -1
  32. package/dist/components/PageHeader.js +2 -2
  33. package/dist/components/Peek.d.ts +3 -4
  34. package/dist/components/Peek.js +9 -6
  35. package/dist/components/Progress.d.ts +3 -1
  36. package/dist/components/Progress.js +5 -3
  37. package/dist/components/Radio.d.ts +3 -1
  38. package/dist/components/Radio.js +6 -2
  39. package/dist/components/Sidebar.js +4 -2
  40. package/dist/components/Spinner.d.ts +3 -1
  41. package/dist/components/Spinner.js +3 -3
  42. package/dist/components/Stat.js +5 -1
  43. package/dist/components/States.d.ts +3 -1
  44. package/dist/components/States.js +8 -2
  45. package/dist/components/Table.d.ts +9 -2
  46. package/dist/components/Table.js +10 -6
  47. package/dist/components/Tabs.d.ts +13 -1
  48. package/dist/components/Tabs.js +78 -3
  49. package/dist/components/Timeline.d.ts +3 -3
  50. package/dist/components/Timeline.js +3 -3
  51. package/dist/components/Toast.js +42 -5
  52. package/dist/components/Tooltip.d.ts +3 -1
  53. package/dist/components/Tooltip.js +44 -5
  54. package/dist/components/Tree.d.ts +2 -0
  55. package/dist/components/Tree.js +26 -4
  56. package/dist/hooks/useDialog.d.ts +1 -1
  57. package/dist/hooks/useDialog.js +17 -2
  58. package/dist/hooks/useTable.d.ts +5 -0
  59. package/dist/hooks/useTable.js +20 -1
  60. package/dist/index.cjs +616 -162
  61. package/dist/index.d.cts +127 -37
  62. package/dist/index.d.ts +2 -2
  63. package/dist/index.js +2 -1
  64. package/dist/tree-model.d.ts +4 -0
  65. package/dist/tree-model.js +8 -0
  66. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
3
  import { highlightSegments } from "../table-engine.js";
4
- function TableWrap({ children, className, ...props }) {
5
- return /* @__PURE__ */ jsx("div", { className: cx("uix-table-wrap", className), ...props, children });
4
+ function TableWrap({ announcement, children, className, ...props }) {
5
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-table-wrap", className), ...props, children: [
6
+ announcement != null && /* @__PURE__ */ jsx("div", { role: "status", className: "uix-visually-hidden", children: announcement }),
7
+ children
8
+ ] });
6
9
  }
7
10
  function Table({
8
11
  density = "standard",
@@ -41,9 +44,9 @@ function Th({ sortable, sortDirection, sortOrder, onSort, className, children, o
41
44
  "data-sort": sortable || void 0,
42
45
  "data-sort-order": sortOrder != null && sortOrder > 0 ? sortOrder : void 0,
43
46
  "aria-sort": sortDirection && sortDirection !== "none" ? sortDirection : void 0,
44
- onClick: sortable ? onSort ?? onClick : onClick,
47
+ onClick: sortable && onSort ? void 0 : onClick,
45
48
  ...props,
46
- children
49
+ children: sortable ? /* @__PURE__ */ jsx("button", { type: "button", className: "uix-th__sortbtn", onClick: onSort, children }) : children
47
50
  }
48
51
  );
49
52
  }
@@ -55,7 +58,7 @@ function Tr({ selected, pinned, className, children, ...props }) {
55
58
  "tr",
56
59
  {
57
60
  className,
58
- "aria-selected": selected || void 0,
61
+ "data-selected": selected || void 0,
59
62
  "data-pinned": pinned || void 0,
60
63
  ...props,
61
64
  children
@@ -78,13 +81,14 @@ function RowActions({ children, className, ...props }) {
78
81
  function RowAction({ children, className, type = "button", ...props }) {
79
82
  return /* @__PURE__ */ jsx("button", { type, className: cx("uix-rowact", className), ...props, children });
80
83
  }
81
- function ExpandToggle({ expanded, className, type = "button", ...props }) {
84
+ function ExpandToggle({ expanded, controls, className, type = "button", ...props }) {
82
85
  return /* @__PURE__ */ jsx(
83
86
  "button",
84
87
  {
85
88
  type,
86
89
  className: cx("uix-table__expand", className),
87
90
  "aria-expanded": expanded,
91
+ "aria-controls": controls,
88
92
  "aria-label": expanded ? "Collapse row" : "Expand row",
89
93
  ...props,
90
94
  children: /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M6 4l4 4-4 4" }) })
@@ -15,5 +15,17 @@ interface TabProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onChange'> {
15
15
  disabled?: boolean;
16
16
  }
17
17
  declare function Tab({ value, children, disabled, className, onClick, ...props }: TabProps): react.JSX.Element;
18
+ interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
19
+ /** The owning `<Tab>`'s value — the panel renders only while that tab is selected. */
20
+ value: string;
21
+ children?: ReactNode;
22
+ }
23
+ /**
24
+ * The tabpanel paired with a `<Tab>` by `value` (UIX-A11Y-2): labelled by its tab,
25
+ * `tabIndex 0` so Tab from the tablist lands in the content, rendered only while
26
+ * selected. Place it as a direct child of `<Tabs>` — it is hoisted out of the tablist —
27
+ * or anywhere the Tabs context reaches.
28
+ */
29
+ declare function TabPanel({ value, children, ...props }: TabPanelProps): react.JSX.Element | null;
18
30
 
19
- export { Tab, type TabProps, Tabs, type TabsProps };
31
+ export { Tab, TabPanel, type TabPanelProps, type TabProps, Tabs, type TabsProps };
@@ -1,10 +1,66 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
3
- import { createContext, useContext } from "react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Children, createContext, isValidElement, useContext, useEffect, useId, useRef } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  const TabsCtx = createContext({});
6
6
  function Tabs({ variant = "line", value, onChange, children, className }) {
7
- return /* @__PURE__ */ jsx(TabsCtx.Provider, { value: { value, onChange }, children: /* @__PURE__ */ jsx("div", { role: "tablist", className: cx("uix-tabs", `uix-tabs--${variant}`, className), children }) });
7
+ const baseId = useId();
8
+ const listRef = useRef(null);
9
+ const kids = Children.toArray(children);
10
+ const panels = kids.filter((k) => isValidElement(k) && k.type === TabPanel);
11
+ const tabs = kids.filter((k) => !(isValidElement(k) && k.type === TabPanel));
12
+ useEffect(() => {
13
+ const list = listRef.current;
14
+ if (!list) return;
15
+ const all = Array.from(list.querySelectorAll('[role="tab"]'));
16
+ const enabled = all.filter((t) => !t.matches(":disabled"));
17
+ const active = enabled.find((t) => t.getAttribute("aria-selected") === "true") ?? enabled[0];
18
+ for (const t of all) t.tabIndex = t === active ? 0 : -1;
19
+ });
20
+ const onKeyDown = (e) => {
21
+ const items = Array.from(
22
+ listRef.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? []
23
+ );
24
+ const cur = e.target.closest('[role="tab"]');
25
+ const idx = cur ? items.indexOf(cur) : -1;
26
+ if (idx < 0 || items.length === 0) return;
27
+ let next;
28
+ switch (e.key) {
29
+ case "ArrowRight":
30
+ next = (idx + 1) % items.length;
31
+ break;
32
+ case "ArrowLeft":
33
+ next = (idx - 1 + items.length) % items.length;
34
+ break;
35
+ case "Home":
36
+ next = 0;
37
+ break;
38
+ case "End":
39
+ next = items.length - 1;
40
+ break;
41
+ default:
42
+ return;
43
+ }
44
+ e.preventDefault();
45
+ const target = items[next];
46
+ if (target) {
47
+ target.focus();
48
+ target.click();
49
+ }
50
+ };
51
+ return /* @__PURE__ */ jsxs(TabsCtx.Provider, { value: { value, onChange, baseId }, children: [
52
+ /* @__PURE__ */ jsx(
53
+ "div",
54
+ {
55
+ ref: listRef,
56
+ role: "tablist",
57
+ className: cx("uix-tabs", `uix-tabs--${variant}`, className),
58
+ onKeyDown,
59
+ children: tabs
60
+ }
61
+ ),
62
+ panels
63
+ ] });
8
64
  }
9
65
  function Tab({ value, children, disabled, className, onClick, ...props }) {
10
66
  const ctx = useContext(TabsCtx);
@@ -13,8 +69,11 @@ function Tab({ value, children, disabled, className, onClick, ...props }) {
13
69
  "button",
14
70
  {
15
71
  role: "tab",
72
+ id: ctx.baseId ? `${ctx.baseId}-tab-${value}` : void 0,
73
+ "aria-controls": ctx.baseId ? `${ctx.baseId}-panel-${value}` : void 0,
16
74
  className: cx("uix-tab", className),
17
75
  "aria-selected": selected,
76
+ tabIndex: selected ? 0 : -1,
18
77
  disabled,
19
78
  onClick: (e) => {
20
79
  ctx.onChange?.(value);
@@ -25,7 +84,23 @@ function Tab({ value, children, disabled, className, onClick, ...props }) {
25
84
  }
26
85
  );
27
86
  }
87
+ function TabPanel({ value, children, ...props }) {
88
+ const ctx = useContext(TabsCtx);
89
+ if (ctx.value !== value) return null;
90
+ return /* @__PURE__ */ jsx(
91
+ "div",
92
+ {
93
+ role: "tabpanel",
94
+ id: ctx.baseId ? `${ctx.baseId}-panel-${value}` : void 0,
95
+ "aria-labelledby": ctx.baseId ? `${ctx.baseId}-tab-${value}` : void 0,
96
+ tabIndex: 0,
97
+ ...props,
98
+ children
99
+ }
100
+ );
101
+ }
28
102
  export {
29
103
  Tab,
104
+ TabPanel,
30
105
  Tabs
31
106
  };
@@ -1,12 +1,12 @@
1
1
  import * as react from 'react';
2
2
  import { HTMLAttributes, ReactNode } from 'react';
3
3
 
4
- interface TimelineProps extends HTMLAttributes<HTMLDivElement> {
4
+ interface TimelineProps extends HTMLAttributes<HTMLOListElement> {
5
5
  children?: ReactNode;
6
6
  }
7
- /** Activity timeline container over `.uix-timeline`. */
7
+ /** Activity timeline container over `.uix-timeline`. Ordered list so AT reads entry count + sequence (UIX-A11Y-4). */
8
8
  declare function Timeline({ children, className, ...props }: TimelineProps): react.JSX.Element;
9
- interface TimelineItemProps extends HTMLAttributes<HTMLDivElement> {
9
+ interface TimelineItemProps extends HTMLAttributes<HTMLLIElement> {
10
10
  /**
11
11
  * Custom rail node — an icon puck, an avatar, a tinted dot. Defaults to the
12
12
  * accent `.uix-timeline__node` dot. Supply this to make a heterogeneous feed
@@ -1,11 +1,11 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
3
  function Timeline({ children, className, ...props }) {
4
- return /* @__PURE__ */ jsx("div", { className: cx("uix-timeline", className), ...props, children });
4
+ return /* @__PURE__ */ jsx("ol", { className: cx("uix-timeline", className), ...props, children });
5
5
  }
6
6
  function TimelineItem({ node, meta, children, className, ...props }) {
7
- return /* @__PURE__ */ jsxs("div", { className: cx("uix-timeline__item", className), ...props, children: [
8
- /* @__PURE__ */ jsxs("div", { className: "uix-timeline__rail", children: [
7
+ return /* @__PURE__ */ jsxs("li", { className: cx("uix-timeline__item", className), ...props, children: [
8
+ /* @__PURE__ */ jsxs("div", { className: "uix-timeline__rail", "aria-hidden": "true", children: [
9
9
  node ?? /* @__PURE__ */ jsx("span", { className: "uix-timeline__node" }),
10
10
  /* @__PURE__ */ jsx("span", { className: "uix-timeline__line" })
11
11
  ] }),
@@ -1,28 +1,65 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
4
  import { cx } from "../cx.js";
5
+ const ToasterContext = createContext(null);
3
6
  function Toast({ title, message, tone, icon, onClose, leaving, className, ...props }) {
4
7
  const assertive = tone === "danger";
8
+ const toaster = useContext(ToasterContext);
9
+ const bodyRef = useRef(null);
10
+ useEffect(() => {
11
+ if (assertive || !toaster) return;
12
+ const text = bodyRef.current?.textContent?.trim();
13
+ if (text) toaster.announce(text);
14
+ }, []);
15
+ const handleClose = (e) => {
16
+ const btn = e.currentTarget;
17
+ if (document.activeElement === btn) {
18
+ const toasterEl = btn.closest(".uix-toaster");
19
+ if (toasterEl) {
20
+ const closes = Array.from(toasterEl.querySelectorAll(".uix-toast__close"));
21
+ const i = closes.indexOf(btn);
22
+ (closes[i + 1] ?? closes[i - 1])?.focus();
23
+ }
24
+ }
25
+ onClose?.();
26
+ };
27
+ const ownLiveRegion = assertive || !toaster;
5
28
  return /* @__PURE__ */ jsxs(
6
29
  "div",
7
30
  {
8
31
  className: cx("uix-toast", tone && `uix-toast--${tone}`, className),
9
32
  "data-leaving": leaving || void 0,
10
- role: assertive ? "alert" : "status",
11
- "aria-live": assertive ? "assertive" : "polite",
33
+ role: ownLiveRegion ? assertive ? "alert" : "status" : void 0,
34
+ "aria-live": ownLiveRegion ? assertive ? "assertive" : "polite" : void 0,
12
35
  ...props,
13
36
  children: [
14
37
  icon && /* @__PURE__ */ jsx("div", { className: "uix-toast__icon", "aria-hidden": "true", children: icon }),
15
- /* @__PURE__ */ jsxs("div", { className: "uix-toast__body", children: [
38
+ /* @__PURE__ */ jsxs("div", { className: "uix-toast__body", ref: bodyRef, children: [
16
39
  title && /* @__PURE__ */ jsx("div", { className: "uix-toast__title", children: title }),
17
40
  message && /* @__PURE__ */ jsx("div", { className: "uix-toast__msg", children: message })
18
41
  ] }),
19
- onClose && /* @__PURE__ */ jsx("button", { className: "uix-toast__close", onClick: onClose, "aria-label": "Dismiss", children: "\xD7" })
42
+ onClose && /* @__PURE__ */ jsx("button", { className: "uix-toast__close", onClick: handleClose, "aria-label": "Dismiss", children: "\xD7" })
20
43
  ]
21
44
  }
22
45
  );
23
46
  }
24
47
  function Toaster({ children, className, ...props }) {
25
- return /* @__PURE__ */ jsx("div", { className: cx("uix-toaster", className), ...props, children });
48
+ const [announced, setAnnounced] = useState("");
49
+ const clearTimer = useRef(null);
50
+ const announce = useCallback((text) => {
51
+ setAnnounced((cur) => cur ? `${cur} ${text}` : text);
52
+ if (clearTimer.current != null) clearTimeout(clearTimer.current);
53
+ clearTimer.current = window.setTimeout(() => setAnnounced(""), 3e3);
54
+ }, []);
55
+ useEffect(() => () => {
56
+ if (clearTimer.current != null) clearTimeout(clearTimer.current);
57
+ }, []);
58
+ const ctx = useMemo(() => ({ announce }), [announce]);
59
+ return /* @__PURE__ */ jsx(ToasterContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxs("div", { className: cx("uix-toaster", className), role: "region", "aria-label": "Notifications", ...props, children: [
60
+ /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: announced }),
61
+ children
62
+ ] }) });
26
63
  }
27
64
  export {
28
65
  Toast,
@@ -13,7 +13,9 @@ interface TooltipProps extends HTMLAttributes<HTMLSpanElement> {
13
13
  * Tooltip whose bubble renders in the top layer via the native Popover API, so it
14
14
  * escapes any `overflow: hidden/scroll` ancestor that would clip the old CSS-only
15
15
  * `[data-uix-tip]` tooltip (UIX-FIX-02). Positioned by the shared overlay engine
16
- * (flip/shift), shown on hover and keyboard focus, dismissed on blur or Escape.
16
+ * (flip/shift), shown on hover and keyboard focus, dismissed on blur or Escape
17
+ * (document-level, per WCAG 1.4.13), and hoverable: the bubble stays open while
18
+ * the pointer is over it (UIX-A11Y-1).
17
19
  */
18
20
  declare function Tooltip({ label, placement, className, children, ...props }: TooltipProps): react.JSX.Element;
19
21
 
@@ -1,15 +1,23 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState } from "react";
3
+ import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
6
6
  function Tooltip({ label, placement = "top", className, children, ...props }) {
7
7
  const triggerRef = useRef(null);
8
8
  const bubbleRef = useRef(null);
9
+ const closeTimer = useRef(null);
9
10
  const [open, setOpen] = useState(false);
10
11
  const id = useId();
11
12
  useAnchoredPosition(triggerRef, bubbleRef, { open, placement, offset: 6 });
13
+ const cancelClose = () => {
14
+ if (closeTimer.current != null) {
15
+ clearTimeout(closeTimer.current);
16
+ closeTimer.current = null;
17
+ }
18
+ };
12
19
  const show = () => {
20
+ cancelClose();
13
21
  const b = bubbleRef.current;
14
22
  if (b && !b.matches(":popover-open")) {
15
23
  try {
@@ -20,6 +28,7 @@ function Tooltip({ label, placement = "top", className, children, ...props }) {
20
28
  setOpen(true);
21
29
  };
22
30
  const hide = () => {
31
+ cancelClose();
23
32
  const b = bubbleRef.current;
24
33
  if (b && b.matches(":popover-open")) {
25
34
  try {
@@ -29,7 +38,20 @@ function Tooltip({ label, placement = "top", className, children, ...props }) {
29
38
  }
30
39
  setOpen(false);
31
40
  };
41
+ const scheduleHide = () => {
42
+ cancelClose();
43
+ closeTimer.current = window.setTimeout(hide, 100);
44
+ };
45
+ useEffect(() => {
46
+ if (!open) return;
47
+ const onKey = (e) => {
48
+ if (e.key === "Escape") hide();
49
+ };
50
+ document.addEventListener("keydown", onKey);
51
+ return () => document.removeEventListener("keydown", onKey);
52
+ }, [open]);
32
53
  useEffect(() => () => {
54
+ if (closeTimer.current != null) clearTimeout(closeTimer.current);
33
55
  const b = bubbleRef.current;
34
56
  if (b && b.matches(":popover-open")) {
35
57
  try {
@@ -39,14 +61,19 @@ function Tooltip({ label, placement = "top", className, children, ...props }) {
39
61
  }
40
62
  }, []);
41
63
  const bubbleAttr = { popover: "manual" };
64
+ const single = isValidElement(children);
65
+ const child = single ? cloneElement(children, {
66
+ "aria-describedby": [children.props["aria-describedby"], id].filter(Boolean).join(" ")
67
+ }) : children;
42
68
  return /* @__PURE__ */ jsxs(
43
69
  "span",
44
70
  {
45
71
  ref: triggerRef,
46
72
  className: cx("uix-tooltip", className),
47
- "aria-describedby": id,
73
+ "aria-describedby": single ? void 0 : id,
74
+ tabIndex: single ? void 0 : 0,
48
75
  onMouseEnter: show,
49
- onMouseLeave: hide,
76
+ onMouseLeave: scheduleHide,
50
77
  onFocus: show,
51
78
  onBlur: hide,
52
79
  onKeyDown: (e) => {
@@ -54,8 +81,20 @@ function Tooltip({ label, placement = "top", className, children, ...props }) {
54
81
  },
55
82
  ...props,
56
83
  children: [
57
- children,
58
- /* @__PURE__ */ jsx("span", { ref: bubbleRef, id, role: "tooltip", className: "uix-tooltip__bubble", ...bubbleAttr, children: label })
84
+ child,
85
+ /* @__PURE__ */ jsx(
86
+ "span",
87
+ {
88
+ ref: bubbleRef,
89
+ id,
90
+ role: "tooltip",
91
+ className: "uix-tooltip__bubble",
92
+ onMouseEnter: cancelClose,
93
+ onMouseLeave: scheduleHide,
94
+ ...bubbleAttr,
95
+ children: label
96
+ }
97
+ )
59
98
  ]
60
99
  }
61
100
  );
@@ -4,6 +4,8 @@ import { HTMLAttributes, ReactNode } from 'react';
4
4
  interface TreeNodeData {
5
5
  id: string;
6
6
  label: ReactNode;
7
+ /** Plain-text label for keyboard typeahead when `label` is not a string (UIX-A11Y-2). */
8
+ typeaheadLabel?: string;
7
9
  icon?: ReactNode;
8
10
  children?: TreeNodeData[];
9
11
  }
@@ -152,6 +152,13 @@ function Tree({
152
152
  setFocusedId(id);
153
153
  break;
154
154
  default:
155
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
156
+ const act = treeNav(flat, id, e.key);
157
+ if (act.focusId !== void 0) {
158
+ e.preventDefault();
159
+ focusItem(rootRef.current?.querySelector(`[role="treeitem"][data-id="${CSS.escape(act.focusId)}"]`));
160
+ }
161
+ }
155
162
  break;
156
163
  }
157
164
  };
@@ -188,8 +195,10 @@ function VirtualTreeView({
188
195
  const scrollRef = useRef(null);
189
196
  const [scrollTop, setScrollTop] = useState(0);
190
197
  const pendingFocus = useRef(false);
198
+ const hadFocus = useRef(false);
191
199
  const win = virtualWindow(scrollTop, maxHeight, rowHeight, flat.length);
192
200
  const rows = flat.slice(win.start, win.end);
201
+ const windowHasTabbable = tabbableId != null && rows.some((f) => f.node.id === tabbableId);
193
202
  useEffect(() => {
194
203
  if (!pendingFocus.current || focusedId == null) return;
195
204
  const el = scrollRef.current?.querySelector(`[role="treeitem"][data-id="${CSS.escape(focusedId)}"]`);
@@ -198,6 +207,11 @@ function VirtualTreeView({
198
207
  pendingFocus.current = false;
199
208
  }
200
209
  }, [focusedId, win.start, win.end]);
210
+ useEffect(() => {
211
+ if (!hadFocus.current || pendingFocus.current) return;
212
+ const active = document.activeElement;
213
+ if (active === document.body || active == null) scrollRef.current?.focus({ preventScroll: true });
214
+ });
201
215
  const moveTo = (id) => {
202
216
  const i = flat.findIndex((f) => f.node.id === id);
203
217
  if (i < 0) return;
@@ -213,6 +227,7 @@ function VirtualTreeView({
213
227
  setFocusedId(id);
214
228
  };
215
229
  const onKeyDown = (e) => {
230
+ if (e.key.length === 1 && (e.ctrlKey || e.metaKey || e.altKey)) return;
216
231
  const curId = e.target.closest('[role="treeitem"]')?.dataset.id ?? focusedId;
217
232
  const act = treeNav(flat, curId, e.key);
218
233
  if (act.focusId === void 0 && act.toggleId === void 0 && act.selectId === void 0) return;
@@ -233,13 +248,20 @@ function VirtualTreeView({
233
248
  ref: scrollRef,
234
249
  className: cx("uix-tree", "uix-tree--virtual", className),
235
250
  role: "tree",
251
+ tabIndex: -1,
236
252
  style: { maxHeight, overflow: "auto" },
237
253
  onScroll: (e) => setScrollTop(e.currentTarget.scrollTop),
238
254
  onKeyDown,
255
+ onFocus: () => {
256
+ hadFocus.current = true;
257
+ },
258
+ onBlur: (e) => {
259
+ if (!e.currentTarget.contains(e.relatedTarget)) hadFocus.current = false;
260
+ },
239
261
  ...props,
240
262
  children: [
241
- win.padTop > 0 && /* @__PURE__ */ jsx("li", { "aria-hidden": "true", style: { height: win.padTop } }),
242
- rows.map((f) => {
263
+ win.padTop > 0 && /* @__PURE__ */ jsx("li", { role: "presentation", "aria-hidden": "true", style: { height: win.padTop } }),
264
+ rows.map((f, i) => {
243
265
  const rowStyle = { height: rowHeight, paddingLeft: `calc(${f.level - 1} * var(--uix-space-5))` };
244
266
  return /* @__PURE__ */ jsx(
245
267
  "li",
@@ -251,7 +273,7 @@ function VirtualTreeView({
251
273
  "aria-expanded": f.hasChildren ? f.isExpanded : void 0,
252
274
  "aria-selected": selectable ? selected === f.node.id : void 0,
253
275
  "data-id": f.node.id,
254
- tabIndex: tabbableId === f.node.id ? 0 : -1,
276
+ tabIndex: tabbableId === f.node.id || !windowHasTabbable && i === 0 ? 0 : -1,
255
277
  className: "uix-tree__item",
256
278
  onFocus: (e) => {
257
279
  if (e.target === e.currentTarget) setFocusedId(f.node.id);
@@ -271,7 +293,7 @@ function VirtualTreeView({
271
293
  f.node.id
272
294
  );
273
295
  }),
274
- win.padBottom > 0 && /* @__PURE__ */ jsx("li", { "aria-hidden": "true", style: { height: win.padBottom } })
296
+ win.padBottom > 0 && /* @__PURE__ */ jsx("li", { role: "presentation", "aria-hidden": "true", style: { height: win.padBottom } })
275
297
  ]
276
298
  }
277
299
  );
@@ -1,3 +1,3 @@
1
- declare function useDialog(open: boolean): React.RefObject<HTMLDialogElement>;
1
+ declare function useDialog(open: boolean, onClose?: () => void): React.RefObject<HTMLDialogElement>;
2
2
 
3
3
  export { useDialog };
@@ -30,8 +30,10 @@ function unlockBodyScroll() {
30
30
  saved = null;
31
31
  }
32
32
  }
33
- function useDialog(open) {
33
+ function useDialog(open, onClose) {
34
34
  const ref = useRef(null);
35
+ const onCloseRef = useRef(onClose);
36
+ onCloseRef.current = onClose;
35
37
  useEffect(() => {
36
38
  const el = ref.current;
37
39
  if (!el || !open) {
@@ -40,8 +42,21 @@ function useDialog(open) {
40
42
  }
41
43
  if (!el.open) el.showModal();
42
44
  lockBodyScroll();
45
+ let locked = true;
46
+ const handleClose = () => {
47
+ if (locked) {
48
+ locked = false;
49
+ unlockBodyScroll();
50
+ }
51
+ onCloseRef.current?.();
52
+ };
53
+ el.addEventListener("close", handleClose);
43
54
  return () => {
44
- unlockBodyScroll();
55
+ el.removeEventListener("close", handleClose);
56
+ if (locked) {
57
+ locked = false;
58
+ unlockBodyScroll();
59
+ }
45
60
  if (el.open) el.close();
46
61
  };
47
62
  }, [open]);
@@ -21,6 +21,11 @@ interface UseTableResult<T> {
21
21
  pinned: ReadonlySet<string>;
22
22
  /** header select-all tri-state over the current derived rows. */
23
23
  selectAll: SelectAllState;
24
+ /**
25
+ * Screen-reader status text reflecting the latest change — "Sorted by {field}, {dir}",
26
+ * "{n} results" (filter/search), "{n} selected". Pass to `<TableWrap announcement>` (UIX-A11Y-2).
27
+ */
28
+ announcement: string;
24
29
  /** cycle a column's sort; pass additive (⇧-click) to keep it as a secondary key. */
25
30
  toggleSort: (field: string, additive?: boolean) => void;
26
31
  setSort: (keys: SortKey[]) => void;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useCallback, useMemo, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import {
4
4
  multiSort,
5
5
  toggleSort,
@@ -34,6 +34,24 @@ function useTable(data, options = {}) {
34
34
  const toggleAllRows = useCallback(() => setSelected((s) => togglePage(s, pageIds)), [pageIds]);
35
35
  const clearSelection = useCallback(() => setSelected(/* @__PURE__ */ new Set()), []);
36
36
  const togglePin = useCallback((id) => setPinned((s) => toggleId(s, id)), []);
37
+ const [announcement, setAnnouncement] = useState("");
38
+ const mounted = useRef(false);
39
+ useEffect(() => {
40
+ if (!mounted.current) return;
41
+ const key = sort[0];
42
+ setAnnouncement(key ? `Sorted by ${key.field}, ${key.dir}` : "Sort cleared");
43
+ }, [sort]);
44
+ useEffect(() => {
45
+ if (!mounted.current) return;
46
+ setAnnouncement(`${rows.length} result${rows.length === 1 ? "" : "s"}`);
47
+ }, [filters, query]);
48
+ useEffect(() => {
49
+ if (!mounted.current) return;
50
+ setAnnouncement(`${selected.size} selected`);
51
+ }, [selected]);
52
+ useEffect(() => {
53
+ mounted.current = true;
54
+ }, []);
37
55
  const view = useMemo(() => ({ sort, filters, q: query }), [sort, filters, query]);
38
56
  const viewQueryString = useMemo(() => serializeView(view), [view]);
39
57
  const applyView = useCallback((queryString) => {
@@ -50,6 +68,7 @@ function useTable(data, options = {}) {
50
68
  selected,
51
69
  pinned,
52
70
  selectAll,
71
+ announcement,
53
72
  toggleSort: doToggleSort,
54
73
  setSort,
55
74
  setFilters,