@tensor_1/react 2.53.0 → 2.54.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
@@ -205,7 +205,7 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
205
205
  - **`Comment variant="system"`** marks a product event, such as a status change, apart from people. It has no speech bubble and a `systemLabel` byline (default "System").
206
206
  - **`Comment avatar={false}`** drops the avatar column (`.uix-comment--bare`), for a comment on a rail that already marks each row, such as a system event in a timeline.
207
207
  - **`Comment replyTo`** quotes the answered message above the body. Its label comes from `replyToLabel` (default "In reply to"). Mentions in a body use the `.uix-mention` class.
208
- - **`SaveStatus`** shows `idle`, `saving`, `saved` or `failed` for inline edits and autosave. It offers `onRetry` when a save fails, and every string comes from `labels`. The status text sits in a polite live region that stays mounted while idle, so keep one `SaveStatus` mounted and change its `state`. Mounting it only when a save starts means the first announcement is lost.
208
+ - **`SaveStatus`** shows `idle`, `saving`, `saved` or `failed` for inline edits and autosave. It offers `onRetry` when a save fails, and every string comes from `labels`. The status text sits in a polite live region that stays mounted while idle, so keep one `SaveStatus` mounted and change its `state`. Mounting it only when a save starts means the first announcement is lost. A failed save's sentence wraps inside its container, with the retry beside or under it, so a long refusal stays readable in a narrow footer.
209
209
  - **`TabPanel keepMounted`** keeps an inactive panel in the DOM, `hidden` and out of the tab order, so switching back keeps its state and data. It is off by default.
210
210
 
211
211
  ```tsx
@@ -732,6 +732,52 @@ enumerateDateKeys({ start: '2026-10-11', end: '2026-10-12' }); // { d
732
732
  - **Deprecated:** `zonedDateSpan` (end-inclusive, swaps inverted ends) and `enumerateDateSpan` (stops silently at
733
733
  370) are unchanged in 2.x. Use `zonedDaySpan` and `enumerateDateKeys`.
734
734
 
735
+ ### Two-axis grids: MatrixInput and MatrixHeatmap
736
+
737
+ `MatrixInput` fills a grid of choices, one per row and column, from the keyboard alone. `MatrixHeatmap` shows how many items sit in each cell, each cell's named band, and a way to the items behind it. Neither has words of its own: `labels`, the axes, the options and the bands come from you.
738
+
739
+ Both are a `role="grid"` table with one tab stop. Arrow keys move between cells, Home and End along a row, and Ctrl+Home and Ctrl+End to the corners. A wide grid scrolls inside its own container with the row headers sticky, so it never widens the page. Set `--uix-matrix-max-height` to keep the column headers in view down a long grid too; without it the page scrolls the grid and they scroll away with it.
740
+
741
+ ```tsx
742
+ <MatrixInput
743
+ aria-label={t('impact')}
744
+ rows={criteria} // { id, label, description? }
745
+ columns={horizons}
746
+ options={[
747
+ { value: 'na', label: t('notRated'), unrated: true },
748
+ { value: 'low', label: t('low'), tone: 'success' },
749
+ { value: 'high', label: t('high'), tone: 'danger' },
750
+ ]}
751
+ value={value} // value[rowId][columnId]
752
+ onChange={(next, change) => setValue(next)}
753
+ rowAxisLabel={t('criterion')}
754
+ labels={{ empty: t('choose') }}
755
+ getCell={(rowId, columnId) => ({ help, error, disabled, readOnly })}
756
+ />
757
+ ```
758
+
759
+ - Each cell is a `Select`: Enter, Space or a typed letter opens the choice, and focus returns to the cell. Keep `options` stable, because a new array redraws every cell.
760
+ - A cell with no value shows `labels.empty`, and so does one naming an option no longer in `options`: keep a retired option in the list, `disabled`, while stored data can hold it. An `unrated` option is a value, drawn apart from both a level and an empty cell.
761
+ - `help` shows under the grid while the cell has focus or the pointer, and a corner mark says a cell has some. `error` shows in the cell.
762
+ - `disabled` and `readOnly`, on the grid or one cell, keep the cell reachable and readable: the keys still move through it.
763
+
764
+ ```tsx
765
+ <MatrixHeatmap
766
+ aria-label={t('risks')}
767
+ rows={impact}
768
+ columns={likelihood}
769
+ cells={[{ rowId: 'severe', columnId: 'likely', count: 12, band: { label: t('critical'), tone: 'danger' } }]}
770
+ onOpenCell={(cell) => openPeek(cell)}
771
+ rowAxisLabel={t('impact')}
772
+ columnAxisLabel={t('likelihood')}
773
+ labels={{ empty: t('noData') }}
774
+ />
775
+ ```
776
+
777
+ - Each cell shows its count and its band, and is named by its two axes, band and count. Give a cell `href`, with `renderLink` for your router, to make it a link: it navigates and does not call `onOpenCell`.
778
+ - `count: 0` shows a zero. `hideCount` shows the band alone, for a zero you would rather not print. A row and column left out of `cells` is drawn empty (dashed, `labels.empty`), and can still be opened: `onOpenCell` gets only its `rowId` and `columnId`.
779
+ - Tones are `neutral`, `info`, `success`, `warning`, `attention` and `danger`, always beside their words.
780
+
735
781
  ### FilterEditor (typed column filters)
736
782
 
737
783
  `FilterEditor` is the content of one column-filter popover with a typed value (HAR-1365; TENSOR's
@@ -0,0 +1,63 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes } from 'react';
3
+ import { UixRenderLink } from '../link.js';
4
+ import { MatrixTone, MatrixAxisItem } from './MatrixParts.js';
5
+
6
+ /** A cell's named band. The name is always shown: colour is never the only cue. */
7
+ interface MatrixBand {
8
+ label: string;
9
+ tone?: MatrixTone;
10
+ }
11
+ /** What one cell of a heatmap holds. */
12
+ interface MatrixHeatmapCell {
13
+ rowId: string;
14
+ columnId: string;
15
+ /** How many items sit in the cell. `0` is shown as a zero unless `hideCount` is set. */
16
+ count?: number;
17
+ band?: MatrixBand;
18
+ /**
19
+ * Show the band without its figure, for a zero the caller would rather not print. Still a cell
20
+ * with data, drawn apart from a cell the caller left out.
21
+ */
22
+ hideCount?: boolean;
23
+ /** Opens as a link rather than a button, through `renderLink`. */
24
+ href?: string;
25
+ }
26
+ /** MatrixHeatmap's words. There are no defaults: the kit carries no language of its own here. */
27
+ interface MatrixHeatmapLabels {
28
+ /** Shown in a cell the caller left out of `cells`. */
29
+ empty: string;
30
+ }
31
+ interface MatrixHeatmapProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'aria-label' | 'aria-labelledby'> {
32
+ /** Names the grid. Give this or `aria-labelledby`. */
33
+ 'aria-label'?: string;
34
+ 'aria-labelledby'?: string;
35
+ rows: readonly MatrixAxisItem[];
36
+ columns: readonly MatrixAxisItem[];
37
+ /** The cells with data. A row and column left out is drawn as empty, and can still be opened. */
38
+ cells: readonly MatrixHeatmapCell[];
39
+ /**
40
+ * The drill-through, for every cell without an `href`. An empty cell comes with only its `rowId`
41
+ * and `columnId`. Without it, a cell with no `href` opens nothing and is marked `aria-disabled`.
42
+ */
43
+ onOpenCell?: (cell: MatrixHeatmapCell) => void;
44
+ /** Draws a cell's `href` with the app's router link. */
45
+ renderLink?: UixRenderLink;
46
+ /** Names the row headers, in the corner. */
47
+ rowAxisLabel: string;
48
+ /** Names the column headers, in the corner. */
49
+ columnAxisLabel?: string;
50
+ labels: MatrixHeatmapLabels;
51
+ }
52
+ /**
53
+ * How many items sit in each cell of a two-axis grid, each cell's named band, and a way to the
54
+ * items behind it (HAR-2753). Every cell is one control. The grid is one tab stop: arrow keys
55
+ * move between cells, Home and End along a row, Ctrl+Home and Ctrl+End to its corners, and Enter
56
+ * opens the cell.
57
+ *
58
+ * Without words of its own: `labels`, the axes and the bands come from the caller. A wide grid
59
+ * scrolls inside its own container with both headers sticky.
60
+ */
61
+ declare function MatrixHeatmap({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, rows, columns, cells, onOpenCell, renderLink, rowAxisLabel, columnAxisLabel, labels, className, ...props }: MatrixHeatmapProps): react.JSX.Element;
62
+
63
+ export { type MatrixBand, MatrixHeatmap, type MatrixHeatmapCell, type MatrixHeatmapLabels, type MatrixHeatmapProps };
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useMemo } from "react";
4
+ import { useMatrixGrid } from "../hooks/useMatrixGrid.js";
5
+ import { renderUixLink } from "../link.js";
6
+ import { MatrixFrame, matrixClass } from "./MatrixParts.js";
7
+ const cellKey = (rowId, columnId) => JSON.stringify([rowId, columnId]);
8
+ function MatrixHeatmap({
9
+ "aria-label": ariaLabel,
10
+ "aria-labelledby": ariaLabelledBy,
11
+ rows,
12
+ columns,
13
+ cells,
14
+ onOpenCell,
15
+ renderLink,
16
+ rowAxisLabel,
17
+ columnAxisLabel,
18
+ labels,
19
+ className,
20
+ ...props
21
+ }) {
22
+ const { gridRef, onCellKeyDown, onGridFocus } = useMatrixGrid(rows.length, columns.length);
23
+ const byCell = useMemo(() => new Map(cells.map((cell) => [cellKey(cell.rowId, cell.columnId), cell])), [cells]);
24
+ return /* @__PURE__ */ jsx("div", { ...props, className: matrixClass(className, "heatmap"), children: /* @__PURE__ */ jsx(
25
+ MatrixFrame,
26
+ {
27
+ gridRef,
28
+ rows,
29
+ columns,
30
+ rowAxisLabel,
31
+ columnAxisLabel,
32
+ ariaLabel,
33
+ ariaLabelledBy,
34
+ readOnly: true,
35
+ onFocus: onGridFocus,
36
+ renderCell: (row, column, rowIndex, columnIndex) => {
37
+ const cell = byCell.get(cellKey(row.id, column.id));
38
+ const figure = cell && !cell.hideCount && cell.count != null ? String(cell.count) : null;
39
+ const empty = figure == null && !cell?.band;
40
+ const detail = empty ? labels.empty : [cell?.band?.label, figure].filter(Boolean).join(", ");
41
+ const name = `${row.label}, ${column.label}${detail ? `: ${detail}` : ""}`;
42
+ const opened = cell ?? { rowId: row.id, columnId: column.id };
43
+ const control = {
44
+ className: "uix-matrix__tile",
45
+ "aria-label": name,
46
+ tabIndex: rowIndex === 0 && columnIndex === 0 ? 0 : -1,
47
+ "data-uix-matrix-cell": "",
48
+ "data-row": rowIndex,
49
+ "data-column": columnIndex,
50
+ "data-tone": cell?.band?.tone,
51
+ "data-empty": empty || void 0,
52
+ "aria-disabled": cell?.href == null && !onOpenCell || void 0,
53
+ onKeyDown: onCellKeyDown,
54
+ onClick: cell?.href != null ? void 0 : () => onOpenCell?.(opened)
55
+ };
56
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
57
+ figure != null && /* @__PURE__ */ jsx("span", { className: "uix-matrix__count", children: figure }),
58
+ /* @__PURE__ */ jsx("span", { className: "uix-matrix__band", children: empty ? labels.empty : cell?.band?.label })
59
+ ] });
60
+ return /* @__PURE__ */ jsx("td", { role: "gridcell", className: "uix-matrix__cell", children: cell?.href != null ? renderUixLink(renderLink, { ...control, href: cell.href, children: content }) : /* @__PURE__ */ jsx("button", { type: "button", ...control, children: content }) }, column.id);
61
+ }
62
+ }
63
+ ) });
64
+ }
65
+ export {
66
+ MatrixHeatmap
67
+ };
@@ -0,0 +1,83 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes } from 'react';
3
+ import { MatrixAxisItem, MatrixTone } from './MatrixParts.js';
4
+
5
+ /** One choice a cell offers. */
6
+ interface MatrixOption {
7
+ /** Not empty: an empty value is a cell with none. */
8
+ value: string;
9
+ /** Shown in the cell and the list: a level is always readable without its colour. */
10
+ label: string;
11
+ tone?: MatrixTone;
12
+ /** A second, muted line in the list. */
13
+ description?: string;
14
+ /**
15
+ * The value that says "looked at, and not rated". It is drawn apart from the levels, and apart
16
+ * from a cell with no value at all, which shows `labels.empty`.
17
+ */
18
+ unrated?: boolean;
19
+ disabled?: boolean;
20
+ }
21
+ /**
22
+ * The choices so far: `value[rowId][columnId]`. A cell without an entry shows `labels.empty`, and
23
+ * so does one naming an option no longer in `options`: keep retired values among the options, marked
24
+ * `disabled`, for as long as stored data can hold them.
25
+ */
26
+ type MatrixValue = Readonly<Record<string, Readonly<Record<string, string | undefined>> | undefined>>;
27
+ /** What one cell changed to. */
28
+ interface MatrixChange {
29
+ rowId: string;
30
+ columnId: string;
31
+ value: string;
32
+ }
33
+ /** What is particular to one cell. */
34
+ interface MatrixCellState {
35
+ /** Shown under the grid while the cell has focus or the pointer. */
36
+ help?: string;
37
+ /** Shown in the cell. */
38
+ error?: string;
39
+ /** Can be reached and read, but not changed, and is drawn dimmed. */
40
+ disabled?: boolean;
41
+ /** Can be reached and read, but not changed. */
42
+ readOnly?: boolean;
43
+ }
44
+ /** MatrixInput's words. There are no defaults: the kit carries no language of its own here. */
45
+ interface MatrixInputLabels {
46
+ /** Shown in a cell with no value. */
47
+ empty: string;
48
+ }
49
+ interface MatrixInputProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue' | 'children' | 'aria-label' | 'aria-labelledby'> {
50
+ /** Names the grid. Give this or `aria-labelledby`. */
51
+ 'aria-label'?: string;
52
+ 'aria-labelledby'?: string;
53
+ rows: readonly MatrixAxisItem[];
54
+ columns: readonly MatrixAxisItem[];
55
+ /** What each cell can hold. Keep this array stable: a new one redraws every cell. */
56
+ options: readonly MatrixOption[];
57
+ value: MatrixValue;
58
+ /** Called with the whole next value, and with the one cell that changed. */
59
+ onChange: (value: MatrixValue, change: MatrixChange) => void;
60
+ /** Names the row headers, in the corner. */
61
+ rowAxisLabel: string;
62
+ /** Names the column headers, in the corner. */
63
+ columnAxisLabel?: string;
64
+ labels: MatrixInputLabels;
65
+ /** Help, an error or a lock for one cell. */
66
+ getCell?: (rowId: string, columnId: string) => MatrixCellState | undefined;
67
+ /** Every cell can be reached and read, but none changed, and all are drawn dimmed. */
68
+ disabled?: boolean;
69
+ /** Every cell can be reached and read, but none changed. */
70
+ readOnly?: boolean;
71
+ }
72
+ /**
73
+ * A grid of choices: one value per row and column, filled from the keyboard alone (HAR-2752).
74
+ * Each cell is a `Select`. The grid is one tab stop: arrow keys move between cells, Home and End
75
+ * along a row, Ctrl+Home and Ctrl+End to its corners; Enter, Space or a typed letter opens the
76
+ * choice, and focus comes back to the cell afterwards.
77
+ *
78
+ * Controlled, and without words of its own: `labels`, the axes and the options come from the
79
+ * caller. A wide grid scrolls inside its own container with both headers sticky.
80
+ */
81
+ declare function MatrixInput({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, rows, columns, options, value, onChange, rowAxisLabel, columnAxisLabel, labels, getCell, disabled, readOnly, className, ...props }: MatrixInputProps): react.JSX.Element;
82
+
83
+ export { type MatrixCellState, type MatrixChange, MatrixInput, type MatrixInputLabels, type MatrixInputProps, type MatrixOption, type MatrixValue };
@@ -0,0 +1,162 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { memo, useCallback, useEffect, useId, useMemo, useRef } from "react";
4
+ import { useMatrixGrid } from "../hooks/useMatrixGrid.js";
5
+ import { MatrixFrame, matrixClass } from "./MatrixParts.js";
6
+ import { Select } from "./Select.js";
7
+ const Cell = memo(function Cell2({
8
+ rowId,
9
+ columnId,
10
+ rowIndex,
11
+ columnIndex,
12
+ name,
13
+ value,
14
+ options,
15
+ option,
16
+ empty,
17
+ help,
18
+ error,
19
+ disabled,
20
+ readOnly,
21
+ id,
22
+ onKeyDown,
23
+ onPick
24
+ }) {
25
+ const locked = disabled || readOnly;
26
+ const helpId = help ? `${id}-help` : void 0;
27
+ const errorId = error ? `${id}-error` : void 0;
28
+ const describedBy = [errorId, helpId].filter(Boolean).join(" ") || void 0;
29
+ return /* @__PURE__ */ jsxs("td", { role: "gridcell", className: "uix-matrix__cell", "aria-readonly": locked || void 0, "aria-invalid": error ? true : void 0, children: [
30
+ /* @__PURE__ */ jsx(
31
+ Select,
32
+ {
33
+ size: "sm",
34
+ "aria-label": name,
35
+ "aria-describedby": describedBy,
36
+ "aria-disabled": disabled || void 0,
37
+ tabIndex: rowIndex === 0 && columnIndex === 0 ? 0 : -1,
38
+ options,
39
+ value,
40
+ placeholder: empty,
41
+ readOnly: locked,
42
+ invalid: !!error,
43
+ "data-uix-matrix-cell": "",
44
+ "data-row": rowIndex,
45
+ "data-column": columnIndex,
46
+ "data-tone": option?.tone,
47
+ "data-unrated": option?.unrated || void 0,
48
+ "data-locked": disabled || void 0,
49
+ "data-help": help,
50
+ onKeyDown,
51
+ onValueChange: (next) => onPick(rowId, columnId, next)
52
+ }
53
+ ),
54
+ help && /* @__PURE__ */ jsx("span", { id: helpId, className: "uix-visually-hidden", children: help }),
55
+ error && /* @__PURE__ */ jsx("span", { id: errorId, className: "uix-matrix__error", children: error })
56
+ ] });
57
+ });
58
+ function MatrixInput({
59
+ "aria-label": ariaLabel,
60
+ "aria-labelledby": ariaLabelledBy,
61
+ rows,
62
+ columns,
63
+ options,
64
+ value,
65
+ onChange,
66
+ rowAxisLabel,
67
+ columnAxisLabel,
68
+ labels,
69
+ getCell,
70
+ disabled = false,
71
+ readOnly = false,
72
+ className,
73
+ ...props
74
+ }) {
75
+ const uid = useId();
76
+ const { gridRef, onCellKeyDown, onGridFocus } = useMatrixGrid(rows.length, columns.length);
77
+ const helpRef = useRef(null);
78
+ const latest = useRef({ value, onChange });
79
+ latest.current = { value, onChange };
80
+ const onPick = useCallback((rowId, columnId, next) => {
81
+ const current = latest.current;
82
+ current.onChange(
83
+ { ...current.value, [rowId]: { ...current.value[rowId], [columnId]: next } },
84
+ { rowId, columnId, value: next }
85
+ );
86
+ }, []);
87
+ const choices = useMemo(() => options.map(({ value: v, label, description, disabled: off }) => ({
88
+ value: v,
89
+ label,
90
+ ...description ? { description } : {},
91
+ ...off ? { disabled: true } : {}
92
+ })), [options]);
93
+ const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
94
+ const showHelp = (cell) => {
95
+ if (helpRef.current) helpRef.current.textContent = cell?.getAttribute("data-help") ?? "";
96
+ };
97
+ const cellOf = (target) => target?.closest?.("[data-uix-matrix-cell]") ?? null;
98
+ const onFocus = (event) => {
99
+ onGridFocus(event);
100
+ showHelp(cellOf(event.target));
101
+ };
102
+ const onPointerOver = (event) => {
103
+ const cell = cellOf(event.target);
104
+ if (cell && event.pointerType === "mouse") showHelp(cell);
105
+ };
106
+ const onPointerLeave = () => showHelp(cellOf(document.activeElement));
107
+ const onBlur = (event) => {
108
+ if (!event.currentTarget.contains(event.relatedTarget)) showHelp(null);
109
+ };
110
+ useEffect(() => {
111
+ const focused = cellOf(document.activeElement);
112
+ if (focused && gridRef.current?.contains(focused)) showHelp(focused);
113
+ });
114
+ const anyHelp = !!getCell && rows.some((row) => columns.some((column) => getCell(row.id, column.id)?.help));
115
+ return /* @__PURE__ */ jsxs("div", { ...props, className: matrixClass(className, "input"), children: [
116
+ /* @__PURE__ */ jsx(
117
+ MatrixFrame,
118
+ {
119
+ gridRef,
120
+ rows,
121
+ columns,
122
+ rowAxisLabel,
123
+ columnAxisLabel,
124
+ ariaLabel,
125
+ ariaLabelledBy,
126
+ readOnly,
127
+ onFocus,
128
+ gridProps: { onPointerOver, onPointerLeave, onBlur },
129
+ renderCell: (row, column, rowIndex, columnIndex) => {
130
+ const state = getCell?.(row.id, column.id);
131
+ const current = value[row.id]?.[column.id] ?? "";
132
+ return /* @__PURE__ */ jsx(
133
+ Cell,
134
+ {
135
+ rowId: row.id,
136
+ columnId: column.id,
137
+ rowIndex,
138
+ columnIndex,
139
+ name: `${row.label}, ${column.label}`,
140
+ value: current,
141
+ options: choices,
142
+ option: byValue.get(current),
143
+ empty: labels.empty,
144
+ help: state?.help,
145
+ error: state?.error,
146
+ disabled: disabled || !!state?.disabled,
147
+ readOnly: readOnly || !!state?.readOnly,
148
+ id: `${uid}-${rowIndex}-${columnIndex}`,
149
+ onKeyDown: onCellKeyDown,
150
+ onPick
151
+ },
152
+ column.id
153
+ );
154
+ }
155
+ }
156
+ ),
157
+ anyHelp && /* @__PURE__ */ jsx("p", { ref: helpRef, className: "uix-matrix__help", "aria-hidden": "true" })
158
+ ] });
159
+ }
160
+ export {
161
+ MatrixInput
162
+ };
@@ -0,0 +1,39 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, RefObject, FocusEventHandler } from 'react';
3
+
4
+ /** A level's or a band's colour. It always comes with words: colour is never the only cue. */
5
+ type MatrixTone = 'neutral' | 'info' | 'success' | 'warning' | 'attention' | 'danger';
6
+ /** One row or column of a matrix. */
7
+ interface MatrixAxisItem {
8
+ id: string;
9
+ /** Names the header, and the cells under it to a screen reader. */
10
+ label: string;
11
+ /** A second, muted line in the header. */
12
+ description?: ReactNode;
13
+ }
14
+ interface MatrixFrameProps {
15
+ gridRef: RefObject<HTMLTableElement>;
16
+ rows: readonly MatrixAxisItem[];
17
+ columns: readonly MatrixAxisItem[];
18
+ /** Names the row headers, in the corner. */
19
+ rowAxisLabel: string;
20
+ /** Names the column headers, in the corner. */
21
+ columnAxisLabel?: string;
22
+ ariaLabel?: string;
23
+ ariaLabelledBy?: string;
24
+ readOnly: boolean;
25
+ onFocus: FocusEventHandler<HTMLElement>;
26
+ gridProps?: Record<string, unknown>;
27
+ /** The cell at a row and a column, as `<td>`. */
28
+ renderCell: (row: MatrixAxisItem, column: MatrixAxisItem, rowIndex: number, columnIndex: number) => ReactNode;
29
+ }
30
+ /**
31
+ * The table both matrices share: a `role="grid"` table with sticky row and column headers, in
32
+ * its own scroller so a wide grid never widens the page. The layout is fixed, so a cell does not
33
+ * change width when its words do.
34
+ */
35
+ declare function MatrixFrame({ gridRef, rows, columns, rowAxisLabel, columnAxisLabel, ariaLabel, ariaLabelledBy, readOnly, onFocus, gridProps, renderCell, }: MatrixFrameProps): react.JSX.Element;
36
+ /** The class of a matrix's outer element. */
37
+ declare const matrixClass: (className: string | undefined, modifier?: string) => string;
38
+
39
+ export { type MatrixAxisItem, MatrixFrame, type MatrixTone, matrixClass };
@@ -0,0 +1,54 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../cx.js";
3
+ function MatrixFrame({
4
+ gridRef,
5
+ rows,
6
+ columns,
7
+ rowAxisLabel,
8
+ columnAxisLabel,
9
+ ariaLabel,
10
+ ariaLabelledBy,
11
+ readOnly,
12
+ onFocus,
13
+ gridProps,
14
+ renderCell
15
+ }) {
16
+ return /* @__PURE__ */ jsx("div", { className: "uix-matrix__scroll", children: /* @__PURE__ */ jsxs(
17
+ "table",
18
+ {
19
+ ...gridProps,
20
+ ref: gridRef,
21
+ role: "grid",
22
+ className: "uix-matrix__grid",
23
+ style: { "--uix-matrix-columns": columns.length },
24
+ "aria-label": ariaLabel,
25
+ "aria-labelledby": ariaLabelledBy,
26
+ "aria-readonly": readOnly || void 0,
27
+ onFocus,
28
+ children: [
29
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
30
+ /* @__PURE__ */ jsxs("th", { scope: "col", className: "uix-matrix__corner", children: [
31
+ columnAxisLabel && /* @__PURE__ */ jsx("span", { className: "uix-matrix__axis uix-matrix__axis--column", children: columnAxisLabel }),
32
+ /* @__PURE__ */ jsx("span", { className: "uix-matrix__axis", children: rowAxisLabel })
33
+ ] }),
34
+ columns.map((column) => /* @__PURE__ */ jsxs("th", { scope: "col", className: "uix-matrix__column", children: [
35
+ column.label,
36
+ column.description != null && /* @__PURE__ */ jsx("span", { className: "uix-matrix__note", children: column.description })
37
+ ] }, column.id))
38
+ ] }) }),
39
+ /* @__PURE__ */ jsx("tbody", { children: rows.map((row, rowIndex) => /* @__PURE__ */ jsxs("tr", { children: [
40
+ /* @__PURE__ */ jsxs("th", { scope: "row", className: "uix-matrix__row", children: [
41
+ row.label,
42
+ row.description != null && /* @__PURE__ */ jsx("span", { className: "uix-matrix__note", children: row.description })
43
+ ] }),
44
+ columns.map((column, columnIndex) => renderCell(row, column, rowIndex, columnIndex))
45
+ ] }, row.id)) })
46
+ ]
47
+ }
48
+ ) });
49
+ }
50
+ const matrixClass = (className, modifier) => cx("uix-matrix", modifier && `uix-matrix--${modifier}`, className);
51
+ export {
52
+ MatrixFrame,
53
+ matrixClass
54
+ };
@@ -0,0 +1,17 @@
1
+ import { RefObject, KeyboardEvent, FocusEvent } from 'react';
2
+
3
+ interface MatrixGrid {
4
+ gridRef: RefObject<HTMLTableElement>;
5
+ /** On each cell's control. A control with an open list (`aria-expanded`) keeps its own keys. */
6
+ onCellKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
7
+ /** On the grid: the focused cell becomes the tab stop. */
8
+ onGridFocus: (event: FocusEvent<HTMLElement>) => void;
9
+ }
10
+ /**
11
+ * One tab stop and arrow-key movement over a grid's cells, written straight to the DOM: moving
12
+ * through a grid of 50 Selects re-renders none of them. The stop survives a re-render, and falls
13
+ * to the cell now in its place when its own cell has gone.
14
+ */
15
+ declare function useMatrixGrid(rows: number, columns: number): MatrixGrid;
16
+
17
+ export { type MatrixGrid, useMatrixGrid };
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
3
+ import { matrixStep } from "../matrix-model.js";
4
+ import { syncRovingStop } from "../roving.js";
5
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
6
+ const CELL = "[data-uix-matrix-cell]";
7
+ function useMatrixGrid(rows, columns) {
8
+ const gridRef = useRef(null);
9
+ const stop = useRef({ node: null, index: 0 });
10
+ const size = useRef({ rows, columns });
11
+ size.current = { rows, columns };
12
+ const cells = () => Array.from(gridRef.current?.querySelectorAll(CELL) ?? []);
13
+ useIsomorphicLayoutEffect(() => {
14
+ stop.current = syncRovingStop(cells(), stop.current);
15
+ });
16
+ const onGridFocus = useCallback((event) => {
17
+ const cell = event.target.closest(CELL);
18
+ if (cell) stop.current = syncRovingStop(cells(), stop.current, cell);
19
+ }, []);
20
+ const onCellKeyDown = useCallback((event) => {
21
+ const cell = event.currentTarget;
22
+ if (cell.getAttribute("aria-expanded") === "true") return;
23
+ const { key, altKey, shiftKey } = event;
24
+ const corner = event.ctrlKey || event.metaKey;
25
+ if (altKey || shiftKey || corner && key !== "Home" && key !== "End") return;
26
+ const to = matrixStep(
27
+ { row: Number(cell.dataset.row), column: Number(cell.dataset.column) },
28
+ size.current,
29
+ key,
30
+ { corner, rtl: getComputedStyle(cell).direction === "rtl" }
31
+ );
32
+ if (!to) return;
33
+ event.preventDefault();
34
+ const next = gridRef.current?.querySelector(`${CELL}[data-row="${to.row}"][data-column="${to.column}"]`);
35
+ if (!next || next === cell) return;
36
+ next.focus({ preventScroll: true });
37
+ next.scrollIntoView?.({ block: "nearest", inline: "nearest" });
38
+ const grid = gridRef.current;
39
+ const header = grid?.tHead?.rows[0]?.cells[0];
40
+ const scroller = grid?.parentElement;
41
+ if (!header || !scroller) return;
42
+ const under = header.getBoundingClientRect().bottom - next.getBoundingClientRect().top;
43
+ if (under > 0) scroller.scrollTop -= under;
44
+ }, []);
45
+ return { gridRef, onCellKeyDown, onGridFocus };
46
+ }
47
+ export {
48
+ useMatrixGrid
49
+ };