vastgrid 0.1.0 → 0.2.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 +175 -13
- package/dist/canvas/geometry.d.ts +92 -0
- package/dist/canvas/geometry.d.ts.map +1 -0
- package/dist/canvas/geometry.js +197 -0
- package/dist/canvas/geometry.js.map +1 -0
- package/dist/canvas/grid-canvas.d.ts +161 -0
- package/dist/canvas/grid-canvas.d.ts.map +1 -0
- package/dist/canvas/grid-canvas.js +1146 -0
- package/dist/canvas/grid-canvas.js.map +1 -0
- package/dist/canvas/hooks.d.ts +61 -0
- package/dist/canvas/hooks.d.ts.map +1 -0
- package/dist/canvas/hooks.js +2 -0
- package/dist/canvas/hooks.js.map +1 -0
- package/dist/canvas/keys.d.ts +26 -0
- package/dist/canvas/keys.d.ts.map +1 -0
- package/dist/canvas/keys.js +133 -0
- package/dist/canvas/keys.js.map +1 -0
- package/dist/canvas/layout.d.ts +26 -0
- package/dist/canvas/layout.d.ts.map +1 -0
- package/dist/canvas/layout.js +73 -0
- package/dist/canvas/layout.js.map +1 -0
- package/dist/canvas/scroll.d.ts +31 -0
- package/dist/canvas/scroll.d.ts.map +1 -0
- package/dist/canvas/scroll.js +111 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/text.d.ts +23 -0
- package/dist/canvas/text.d.ts.map +1 -0
- package/dist/canvas/text.js +142 -0
- package/dist/canvas/text.js.map +1 -0
- package/dist/{theme.d.ts → canvas/theme.d.ts} +8 -0
- package/dist/canvas/theme.d.ts.map +1 -0
- package/dist/canvas/theme.js +62 -0
- package/dist/canvas/theme.js.map +1 -0
- package/dist/core/cache.d.ts +48 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +222 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/checked.d.ts +20 -0
- package/dist/core/checked.d.ts.map +1 -0
- package/dist/core/checked.js +61 -0
- package/dist/core/checked.js.map +1 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +103 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/conditions.d.ts +48 -0
- package/dist/core/conditions.d.ts.map +1 -0
- package/dist/core/conditions.js +105 -0
- package/dist/core/conditions.js.map +1 -0
- package/dist/core/evaluate.d.ts +20 -0
- package/dist/core/evaluate.d.ts.map +1 -0
- package/dist/core/evaluate.js +210 -0
- package/dist/core/evaluate.js.map +1 -0
- package/dist/core/filters.d.ts +51 -0
- package/dist/core/filters.d.ts.map +1 -0
- package/dist/core/filters.js +303 -0
- package/dist/core/filters.js.map +1 -0
- package/dist/core/format.d.ts +14 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +303 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/measures.d.ts +26 -0
- package/dist/core/measures.d.ts.map +1 -0
- package/dist/core/measures.js +105 -0
- package/dist/core/measures.js.map +1 -0
- package/dist/core/request-ids.d.ts +20 -0
- package/dist/core/request-ids.d.ts.map +1 -0
- package/dist/core/request-ids.js +55 -0
- package/dist/core/request-ids.js.map +1 -0
- package/dist/core/rows.d.ts +79 -0
- package/dist/core/rows.d.ts.map +1 -0
- package/dist/core/rows.js +173 -0
- package/dist/core/rows.js.map +1 -0
- package/dist/core/store.d.ts +233 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/store.js +1162 -0
- package/dist/core/store.js.map +1 -0
- package/dist/core/totals.d.ts +24 -0
- package/dist/core/totals.d.ts.map +1 -0
- package/dist/core/totals.js +49 -0
- package/dist/core/totals.js.map +1 -0
- package/dist/core/trace.d.ts +7 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +26 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/core/transports.d.ts +50 -0
- package/dist/core/transports.d.ts.map +1 -0
- package/dist/core/transports.js +160 -0
- package/dist/core/transports.js.map +1 -0
- package/dist/core/types.d.ts +150 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +45 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +11 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/dist/react/accessible-rows.d.ts +13 -0
- package/dist/react/accessible-rows.d.ts.map +1 -0
- package/dist/react/accessible-rows.js +320 -0
- package/dist/react/accessible-rows.js.map +1 -0
- package/dist/react/cell-editor.d.ts +22 -0
- package/dist/react/cell-editor.d.ts.map +1 -0
- package/dist/react/cell-editor.js +431 -0
- package/dist/react/cell-editor.js.map +1 -0
- package/dist/react/cell-menu.d.ts +18 -0
- package/dist/react/cell-menu.d.ts.map +1 -0
- package/dist/react/cell-menu.js +299 -0
- package/dist/react/cell-menu.js.map +1 -0
- package/dist/react/column-menu.d.ts +16 -0
- package/dist/react/column-menu.d.ts.map +1 -0
- package/dist/react/column-menu.js +1311 -0
- package/dist/react/column-menu.js.map +1 -0
- package/dist/react/columns-menu.d.ts +7 -0
- package/dist/react/columns-menu.d.ts.map +1 -0
- package/dist/react/columns-menu.js +249 -0
- package/dist/react/columns-menu.js.map +1 -0
- package/dist/react/conditions-menu.d.ts +10 -0
- package/dist/react/conditions-menu.d.ts.map +1 -0
- package/dist/react/conditions-menu.js +795 -0
- package/dist/react/conditions-menu.js.map +1 -0
- package/dist/react/context.d.ts +29 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +18 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/data-grid.d.ts +79 -0
- package/dist/react/data-grid.d.ts.map +1 -0
- package/dist/react/data-grid.js +653 -0
- package/dist/react/data-grid.js.map +1 -0
- package/dist/react/detail-panel.d.ts +22 -0
- package/dist/react/detail-panel.d.ts.map +1 -0
- package/dist/react/detail-panel.js +242 -0
- package/dist/react/detail-panel.js.map +1 -0
- package/dist/react/export-menu.d.ts +11 -0
- package/dist/react/export-menu.d.ts.map +1 -0
- package/dist/react/export-menu.js +304 -0
- package/dist/react/export-menu.js.map +1 -0
- package/dist/react/find-bar.d.ts +11 -0
- package/dist/react/find-bar.d.ts.map +1 -0
- package/dist/react/find-bar.js +303 -0
- package/dist/react/find-bar.js.map +1 -0
- package/dist/react/format-form.d.ts +12 -0
- package/dist/react/format-form.d.ts.map +1 -0
- package/dist/react/format-form.js +863 -0
- package/dist/react/format-form.js.map +1 -0
- package/dist/react/formula-menu.d.ts +6 -0
- package/dist/react/formula-menu.d.ts.map +1 -0
- package/dist/react/formula-menu.js +343 -0
- package/dist/react/formula-menu.js.map +1 -0
- package/dist/react/grid-body.d.ts +33 -0
- package/dist/react/grid-body.d.ts.map +1 -0
- package/dist/react/grid-body.js +1389 -0
- package/dist/react/grid-body.js.map +1 -0
- package/dist/react/group-menu.d.ts +7 -0
- package/dist/react/group-menu.d.ts.map +1 -0
- package/dist/react/group-menu.js +508 -0
- package/dist/react/group-menu.js.map +1 -0
- package/dist/react/hooks.d.ts +33 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +161 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/messages.d.ts +180 -0
- package/dist/react/messages.d.ts.map +1 -0
- package/dist/react/messages.js +414 -0
- package/dist/react/messages.js.map +1 -0
- package/dist/react/save.d.ts +9 -0
- package/dist/react/save.d.ts.map +1 -0
- package/dist/react/save.js +102 -0
- package/dist/react/save.js.map +1 -0
- package/dist/react/static-rows.d.ts +9 -0
- package/dist/react/static-rows.d.ts.map +1 -0
- package/dist/react/static-rows.js +176 -0
- package/dist/react/static-rows.js.map +1 -0
- package/dist/react/status-bar.d.ts +12 -0
- package/dist/react/status-bar.d.ts.map +1 -0
- package/dist/react/status-bar.js +413 -0
- package/dist/react/status-bar.js.map +1 -0
- package/dist/react/toolbar-popover.d.ts +14 -0
- package/dist/react/toolbar-popover.d.ts.map +1 -0
- package/dist/react/toolbar-popover.js +143 -0
- package/dist/react/toolbar-popover.js.map +1 -0
- package/dist/react/toolbar.d.ts +13 -0
- package/dist/react/toolbar.d.ts.map +1 -0
- package/dist/react/toolbar.js +344 -0
- package/dist/react/toolbar.js.map +1 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +45 -0
- package/dist/server.js.map +1 -0
- package/dist/styles.css +496 -316
- package/package.json +43 -18
- package/src/canvas/geometry.ts +197 -0
- package/src/canvas/grid-canvas.ts +1222 -0
- package/src/canvas/hooks.ts +61 -0
- package/src/canvas/keys.ts +96 -0
- package/src/canvas/layout.ts +77 -0
- package/src/canvas/scroll.ts +120 -0
- package/src/canvas/text.ts +155 -0
- package/src/{theme.ts → canvas/theme.ts} +14 -1
- package/src/core/cache.ts +247 -0
- package/src/core/checked.ts +64 -0
- package/src/core/client.ts +103 -0
- package/src/core/conditions.ts +89 -0
- package/src/core/evaluate.ts +221 -0
- package/src/core/filters.ts +268 -0
- package/src/core/format.ts +309 -0
- package/src/core/measures.ts +99 -0
- package/src/core/request-ids.ts +54 -0
- package/src/core/rows.ts +213 -0
- package/src/core/store.ts +1122 -0
- package/src/core/totals.ts +59 -0
- package/src/core/trace.ts +44 -0
- package/src/core/transports.ts +177 -0
- package/src/core/types.ts +168 -0
- package/src/core.ts +39 -0
- package/src/index.ts +24 -12
- package/src/react/accessible-rows.tsx +242 -0
- package/src/react/cell-editor.tsx +216 -0
- package/src/react/cell-menu.tsx +166 -0
- package/src/react/column-menu.tsx +619 -0
- package/src/react/columns-menu.tsx +116 -0
- package/src/react/conditions-menu.tsx +289 -0
- package/src/react/context.ts +33 -0
- package/src/react/data-grid.tsx +286 -0
- package/src/react/detail-panel.tsx +92 -0
- package/src/react/export-menu.tsx +112 -0
- package/src/react/find-bar.tsx +112 -0
- package/src/react/format-form.tsx +421 -0
- package/src/react/formula-menu.tsx +163 -0
- package/src/react/grid-body.tsx +744 -0
- package/src/react/group-menu.tsx +214 -0
- package/src/react/hooks.ts +104 -0
- package/src/react/messages.ts +556 -0
- package/src/react/save.ts +42 -0
- package/src/react/static-rows.tsx +59 -0
- package/src/react/status-bar.tsx +175 -0
- package/src/react/toolbar-popover.tsx +69 -0
- package/src/react/toolbar.tsx +152 -0
- package/src/server.ts +58 -0
- package/src/styles.css +496 -316
- package/dist/components/bars.d.ts +0 -14
- package/dist/components/bars.d.ts.map +0 -1
- package/dist/components/bars.js +0 -67
- package/dist/components/bars.js.map +0 -1
- package/dist/components/cell-inspector.d.ts +0 -11
- package/dist/components/cell-inspector.d.ts.map +0 -1
- package/dist/components/cell-inspector.js +0 -37
- package/dist/components/cell-inspector.js.map +0 -1
- package/dist/components/columns-panel.d.ts +0 -12
- package/dist/components/columns-panel.d.ts.map +0 -1
- package/dist/components/columns-panel.js +0 -11
- package/dist/components/columns-panel.js.map +0 -1
- package/dist/components/filter-panel.d.ts +0 -17
- package/dist/components/filter-panel.d.ts.map +0 -1
- package/dist/components/filter-panel.js +0 -165
- package/dist/components/filter-panel.js.map +0 -1
- package/dist/components/find-bar.d.ts +0 -9
- package/dist/components/find-bar.d.ts.map +0 -1
- package/dist/components/find-bar.js +0 -46
- package/dist/components/find-bar.js.map +0 -1
- package/dist/components/popover.d.ts +0 -38
- package/dist/components/popover.d.ts.map +0 -1
- package/dist/components/popover.js +0 -104
- package/dist/components/popover.js.map +0 -1
- package/dist/components/stats-panel.d.ts +0 -12
- package/dist/components/stats-panel.d.ts.map +0 -1
- package/dist/components/stats-panel.js +0 -40
- package/dist/components/stats-panel.js.map +0 -1
- package/dist/controller.d.ts +0 -218
- package/dist/controller.d.ts.map +0 -1
- package/dist/controller.js +0 -788
- package/dist/controller.js.map +0 -1
- package/dist/data-grid.d.ts +0 -51
- package/dist/data-grid.d.ts.map +0 -1
- package/dist/data-grid.js +0 -698
- package/dist/data-grid.js.map +0 -1
- package/dist/labels.d.ts +0 -9
- package/dist/labels.d.ts.map +0 -1
- package/dist/labels.js +0 -38
- package/dist/labels.js.map +0 -1
- package/dist/messages.d.ts +0 -99
- package/dist/messages.d.ts.map +0 -1
- package/dist/messages.js +0 -276
- package/dist/messages.js.map +0 -1
- package/dist/renderer.d.ts +0 -52
- package/dist/renderer.d.ts.map +0 -1
- package/dist/renderer.js +0 -522
- package/dist/renderer.js.map +0 -1
- package/dist/text.d.ts +0 -14
- package/dist/text.d.ts.map +0 -1
- package/dist/text.js +0 -63
- package/dist/text.js.map +0 -1
- package/dist/theme.d.ts.map +0 -1
- package/dist/theme.js +0 -54
- package/dist/theme.js.map +0 -1
- package/src/components/bars.tsx +0 -179
- package/src/components/cell-inspector.tsx +0 -70
- package/src/components/columns-panel.tsx +0 -59
- package/src/components/filter-panel.tsx +0 -290
- package/src/components/find-bar.tsx +0 -104
- package/src/components/popover.tsx +0 -194
- package/src/components/stats-panel.tsx +0 -153
- package/src/controller.ts +0 -887
- package/src/data-grid.tsx +0 -1029
- package/src/labels.ts +0 -46
- package/src/messages.ts +0 -390
- package/src/renderer.ts +0 -535
- package/src/text.ts +0 -55
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { startTransition, useEffect, useState } from "react";
|
|
2
|
+
import { type ActiveCell, type CellRange, type DisplayedColumn, type SortKey, textOf } from "vastgrid-spec";
|
|
3
|
+
import type { GridCanvas, Settled } from "../canvas/grid-canvas.js";
|
|
4
|
+
import type { GridStore } from "../core/store.js";
|
|
5
|
+
import { useGridSelector, useGridStore } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
/** Rows and columns that the accessibility tree holds at most. */
|
|
8
|
+
const MAX_ROWS = 200;
|
|
9
|
+
const MAX_COLUMNS = 60;
|
|
10
|
+
|
|
11
|
+
/** The id of the element of a cell, for `aria-activedescendant`. */
|
|
12
|
+
export function cellId(id: string, row: number, column: number) {
|
|
13
|
+
return `${id}-r${row}-c${column}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Box {
|
|
17
|
+
left: number;
|
|
18
|
+
top: number;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Cell extends Box {
|
|
24
|
+
column: number;
|
|
25
|
+
name: string;
|
|
26
|
+
text: string;
|
|
27
|
+
/** What the colors of a style rule mean (§5.1), read by screen readers. */
|
|
28
|
+
description?: string;
|
|
29
|
+
/** A link or a button (`cellAction`), with its name for screen readers. */
|
|
30
|
+
action?: { kind: "link" | "button"; label?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ShownRow {
|
|
34
|
+
row: number;
|
|
35
|
+
cells: Cell[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The display positions of the columns on screen: those that scroll into view, and those pinned. */
|
|
39
|
+
function shownColumns(settled: Settled, columns: readonly DisplayedColumn[]): number[] {
|
|
40
|
+
const shown: number[] = [];
|
|
41
|
+
for (let column = 0; column < columns.length && shown.length < MAX_COLUMNS; column++) {
|
|
42
|
+
const scrolls = column >= settled.left && column <= settled.right;
|
|
43
|
+
if (scrolls || columns[column].setting.pin !== undefined) shown.push(column);
|
|
44
|
+
}
|
|
45
|
+
return shown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A box of the canvas, in the scroller's content. */
|
|
49
|
+
function boxOf(canvas: GridCanvas, x: number, y: number, width: number, height: number): Box {
|
|
50
|
+
return { left: canvas.geometry.scrollX.native + x, top: canvas.geometry.scrollY.native + y, width, height };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A row's cells; a row that is not on screen is placed at the top of the screen. */
|
|
54
|
+
function readRow(
|
|
55
|
+
canvas: GridCanvas,
|
|
56
|
+
store: GridStore,
|
|
57
|
+
columns: readonly DisplayedColumn[],
|
|
58
|
+
shown: readonly number[],
|
|
59
|
+
row: number,
|
|
60
|
+
onScreen: boolean,
|
|
61
|
+
): ShownRow {
|
|
62
|
+
const { geometry } = canvas;
|
|
63
|
+
const y = onScreen ? geometry.rowY(row) : geometry.headerHeight;
|
|
64
|
+
const cells = shown.map((column) => {
|
|
65
|
+
const item = columns[column].column;
|
|
66
|
+
const value = store.cell(row, item.name);
|
|
67
|
+
return {
|
|
68
|
+
column,
|
|
69
|
+
name: item.name,
|
|
70
|
+
text: value === undefined || value === null ? "" : canvas.text(row, item, value),
|
|
71
|
+
description: canvas.styleOf(row, item.name)?.description,
|
|
72
|
+
action: canvas.actionAt(row, column)?.action,
|
|
73
|
+
...boxOf(canvas, geometry.columnX(column), y, geometry.columnWidth(column), geometry.rowHeight),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return { row, cells };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where the headers and the cells on screen are, in the scroller's content, and their text. The canvas and the
|
|
81
|
+
* cache change inside their objects, so `settled` is an argument: they are read again when scrolling pauses or
|
|
82
|
+
* rows arrive (memoization, React Compiler's included, keys on it), and not when the selection moves.
|
|
83
|
+
*/
|
|
84
|
+
function readScreen(canvas: GridCanvas, store: GridStore, settled: Settled, columns: readonly DisplayedColumn[]) {
|
|
85
|
+
const { geometry } = canvas;
|
|
86
|
+
const shown = shownColumns(settled, columns);
|
|
87
|
+
const header = shown.map((column) => ({
|
|
88
|
+
column,
|
|
89
|
+
name: columns[column].column.name,
|
|
90
|
+
text: columns[column].setting.format?.label ?? columns[column].column.name,
|
|
91
|
+
groups: canvas.groupNames(column).join(" › "),
|
|
92
|
+
...boxOf(canvas, geometry.columnX(column), 0, geometry.columnWidth(column), geometry.headerHeight),
|
|
93
|
+
}));
|
|
94
|
+
const rows: ShownRow[] = [];
|
|
95
|
+
const end = Math.min(settled.top + settled.rows, settled.top + MAX_ROWS);
|
|
96
|
+
for (let row = settled.top; row < end; row++) rows.push(readRow(canvas, store, columns, shown, row, true));
|
|
97
|
+
return { header, rows, shown, end };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The display positions of a row's selected cells (§5.2), as text that stays the same while they do. */
|
|
101
|
+
function selectedOf(ranges: readonly CellRange[] | undefined, active: ActiveCell | undefined, row: ShownRow) {
|
|
102
|
+
const selected: number[] = [];
|
|
103
|
+
for (const cell of row.cells) {
|
|
104
|
+
const inRange = (ranges ?? []).some(
|
|
105
|
+
(range) =>
|
|
106
|
+
(range.rows === "all" || (row.row >= range.rows[0] && row.row < range.rows[1])) &&
|
|
107
|
+
(range.columns === "all" || range.columns.includes(cell.name)),
|
|
108
|
+
);
|
|
109
|
+
if (inRange || (active?.row === row.row && active.column === cell.name)) selected.push(cell.column);
|
|
110
|
+
}
|
|
111
|
+
return selected.join(",");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sortOf(sort: readonly SortKey[] | undefined, column: string) {
|
|
115
|
+
const key = sort?.find((item) => item.column === column);
|
|
116
|
+
return key ? (key.direction === "asc" ? "ascending" : "descending") : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const style = (box: Box) => ({ left: box.left, top: box.top, width: box.width, height: box.height });
|
|
120
|
+
|
|
121
|
+
/** A row of the accessibility tree; it renders again only when its cells or its selected cells change. */
|
|
122
|
+
function AccessibleRow({ id, row, selected }: { id: string; row: ShownRow; selected: string }) {
|
|
123
|
+
const marked = new Set(selected === "" ? [] : selected.split(",").map(Number));
|
|
124
|
+
return (
|
|
125
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
126
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
127
|
+
<div role="row" aria-rowindex={row.row + 2}>
|
|
128
|
+
{row.cells.map((cell) => (
|
|
129
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
130
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
131
|
+
<div
|
|
132
|
+
key={cell.name}
|
|
133
|
+
id={cellId(id, row.row, cell.column)}
|
|
134
|
+
role="gridcell"
|
|
135
|
+
aria-colindex={cell.column + 1}
|
|
136
|
+
aria-selected={marked.has(cell.column)}
|
|
137
|
+
aria-description={cell.description}
|
|
138
|
+
style={style(cell)}
|
|
139
|
+
>
|
|
140
|
+
{cell.action?.kind === "link" ? (
|
|
141
|
+
// biome-ignore lint/a11y/useSemanticElements: the cell's link is drawn on the canvas; this names it.
|
|
142
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
143
|
+
<span role="link" aria-label={cell.action.label}>
|
|
144
|
+
{cell.text}
|
|
145
|
+
</span>
|
|
146
|
+
) : cell.action?.kind === "button" ? (
|
|
147
|
+
// biome-ignore lint/a11y/useSemanticElements: the cell's button is drawn on the canvas; this names it.
|
|
148
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
149
|
+
<span role="button" aria-label={cell.action.label}>
|
|
150
|
+
{cell.text}
|
|
151
|
+
</span>
|
|
152
|
+
) : (
|
|
153
|
+
cell.text
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The rows on screen as ARIA rows and cells under the canvas (§6.4 of react.md), placed over the cells they
|
|
163
|
+
* stand for and drawn transparent, with the row of the active cell when it is elsewhere. They follow the canvas
|
|
164
|
+
* when scrolling pauses, in a Transition: one commit at a lower priority than anything the person does.
|
|
165
|
+
*/
|
|
166
|
+
export function AccessibleRows({ canvas, id }: { canvas: GridCanvas; id: string }) {
|
|
167
|
+
const { geometry } = canvas;
|
|
168
|
+
const store = useGridStore();
|
|
169
|
+
const [settled, setSettled] = useState(() => canvas.getSettled());
|
|
170
|
+
useEffect(
|
|
171
|
+
() =>
|
|
172
|
+
canvas.subscribeSettled(() => {
|
|
173
|
+
startTransition(() => setSettled(canvas.getSettled()));
|
|
174
|
+
}),
|
|
175
|
+
[canvas],
|
|
176
|
+
);
|
|
177
|
+
const columns = useGridSelector((snapshot) => snapshot.displayed);
|
|
178
|
+
const sort = useGridSelector((snapshot) => snapshot.document.plan?.sort);
|
|
179
|
+
const selection = useGridSelector((snapshot) => snapshot.session.selection);
|
|
180
|
+
const active = useGridSelector((snapshot) => snapshot.session.active);
|
|
181
|
+
const totals = useGridSelector((snapshot) => snapshot.totals);
|
|
182
|
+
const screen = readScreen(canvas, store, settled, columns);
|
|
183
|
+
const elsewhere =
|
|
184
|
+
active && (active.row < settled.top || active.row >= screen.end) && active.row < geometry.rowCount
|
|
185
|
+
? readRow(canvas, store, columns, screen.shown, active.row, false)
|
|
186
|
+
: null;
|
|
187
|
+
const rows = elsewhere ? [...screen.rows, elsewhere].sort((a, b) => a.row - b.row) : screen.rows;
|
|
188
|
+
return (
|
|
189
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
190
|
+
<div className="vg-a11y" role="rowgroup">
|
|
191
|
+
{/* biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas. */}
|
|
192
|
+
{/* biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant). */}
|
|
193
|
+
<div role="row" aria-rowindex={1}>
|
|
194
|
+
{screen.header.map((cell) => (
|
|
195
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
196
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
197
|
+
<div
|
|
198
|
+
key={cell.name}
|
|
199
|
+
role="columnheader"
|
|
200
|
+
aria-colindex={cell.column + 1}
|
|
201
|
+
aria-sort={sortOf(sort, cell.name)}
|
|
202
|
+
aria-description={cell.groups || undefined}
|
|
203
|
+
style={style(cell)}
|
|
204
|
+
>
|
|
205
|
+
{cell.text}
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
{rows.map((row) => (
|
|
210
|
+
<AccessibleRow key={row.row} id={id} row={row} selected={selectedOf(selection, active, row)} />
|
|
211
|
+
))}
|
|
212
|
+
{geometry.footer > 0 && (
|
|
213
|
+
// The totals row (§5.1 of the specification), after the rows.
|
|
214
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
215
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
216
|
+
<div role="row" aria-rowindex={geometry.rowCount + 2}>
|
|
217
|
+
{screen.header.map((cell) => {
|
|
218
|
+
const value = totals?.values[cell.name];
|
|
219
|
+
const type = totals?.columns[cell.name];
|
|
220
|
+
return (
|
|
221
|
+
// biome-ignore lint/a11y/useSemanticElements: ARIA grid pattern over a canvas.
|
|
222
|
+
// biome-ignore lint/a11y/useFocusableInteractive: focus stays on the grid (aria-activedescendant).
|
|
223
|
+
<div
|
|
224
|
+
key={cell.name}
|
|
225
|
+
role="gridcell"
|
|
226
|
+
aria-colindex={cell.column + 1}
|
|
227
|
+
style={{
|
|
228
|
+
left: cell.left,
|
|
229
|
+
top: geometry.bottom,
|
|
230
|
+
width: cell.width,
|
|
231
|
+
height: geometry.footer,
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{value === undefined || value === null || !type ? "" : (textOf(value, type) ?? "")}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompositionEvent,
|
|
3
|
+
type FocusEvent,
|
|
4
|
+
type FormEvent,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type Ref,
|
|
7
|
+
type RefObject,
|
|
8
|
+
use,
|
|
9
|
+
useId,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useLayoutEffect,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { CommandError, type Editor } from "vastgrid-spec";
|
|
16
|
+
import type { GridCanvas } from "../canvas/grid-canvas.js";
|
|
17
|
+
import { isApple, keyCommand } from "../canvas/keys.js";
|
|
18
|
+
import type { GridStore } from "../core/store.js";
|
|
19
|
+
import { LocaleContext, SaveContext } from "./context.js";
|
|
20
|
+
import { useGridSelector, useGridStore } from "./hooks.js";
|
|
21
|
+
|
|
22
|
+
/** What the grid asks of the editor while it handles a key. */
|
|
23
|
+
export interface EditorControl {
|
|
24
|
+
/** Places the editor over the editor's cell and gives it the focus, before the key's text arrives. */
|
|
25
|
+
open(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether an editor of the session is for the same cell as another. */
|
|
29
|
+
function sameCell(a: Editor | undefined, b: Editor | undefined) {
|
|
30
|
+
return a !== undefined && b !== undefined && a.row === b.row && a.column === b.column;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Grows the editor with its lines, from the cell's height up. */
|
|
34
|
+
function fit(textarea: HTMLTextAreaElement) {
|
|
35
|
+
textarea.style.height = "";
|
|
36
|
+
textarea.style.height = `${Math.max(textarea.offsetHeight, textarea.scrollHeight)}px`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Places the editor's box over the cell of the session's editor, hidden while the cell is scrolled away. */
|
|
40
|
+
function place(box: HTMLDivElement | null, store: GridStore, canvas: GridCanvas) {
|
|
41
|
+
const current = store.getSnapshot().session.editor;
|
|
42
|
+
if (!box || !current) return;
|
|
43
|
+
const at = store.getSnapshot().displayed.findIndex((item) => item.column.name === current.column);
|
|
44
|
+
if (at < 0) return;
|
|
45
|
+
const cell = canvas.geometry.cellBox(current.row, at);
|
|
46
|
+
box.style.left = `${cell.left}px`;
|
|
47
|
+
box.style.top = `${cell.top}px`;
|
|
48
|
+
box.style.minWidth = `${Math.max(cell.width, 60)}px`;
|
|
49
|
+
box.style.setProperty("--vg-editor-height", `${cell.height}px`);
|
|
50
|
+
box.style.visibility = cell.shown ? "" : "hidden";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CellEditorProps {
|
|
54
|
+
canvas: GridCanvas;
|
|
55
|
+
scrollerRef: RefObject<HTMLDivElement | null>;
|
|
56
|
+
announce: (text: string) => void;
|
|
57
|
+
ref?: Ref<EditorControl>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The text field over the cell being edited (§6.5 of the specification), the session's `editor`. It stays in the
|
|
62
|
+
* page while no cell is edited, so that a key that types text, an IME's included, moves into it with its text:
|
|
63
|
+
* the grid opens the editor and gives it the focus before the browser handles the key. Enter saves and moves down
|
|
64
|
+
* (Shift+Enter up), Tab saves and moves right (Shift+Tab left), Esc cancels, and Alt+Enter (also ⌃↩ on Apple
|
|
65
|
+
* platforms) starts a new line. Keys that end an IME composition are left to the IME.
|
|
66
|
+
*/
|
|
67
|
+
export function CellEditor({ canvas, scrollerRef, announce, ref }: CellEditorProps) {
|
|
68
|
+
const store = useGridStore();
|
|
69
|
+
const { messages, number } = use(LocaleContext);
|
|
70
|
+
const saving = use(SaveContext);
|
|
71
|
+
const editor = useGridSelector((snapshot) => snapshot.session.editor);
|
|
72
|
+
const columns = useGridSelector((snapshot) => snapshot.displayed);
|
|
73
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
75
|
+
const shown = useRef<Editor | undefined>(undefined);
|
|
76
|
+
const [problem, setProblem] = useState<{ editor: Editor; text: string } | null>(null);
|
|
77
|
+
const hintId = useId();
|
|
78
|
+
const errorId = useId();
|
|
79
|
+
const column = editor ? columns.findIndex((item) => item.column.name === editor.column) : -1;
|
|
80
|
+
const open = editor !== undefined && column >= 0;
|
|
81
|
+
const error = problem && sameCell(problem.editor, editor) ? problem.text : null;
|
|
82
|
+
|
|
83
|
+
useImperativeHandle(
|
|
84
|
+
ref,
|
|
85
|
+
() => ({
|
|
86
|
+
open: () => {
|
|
87
|
+
place(boxRef.current, store, canvas);
|
|
88
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
[store, canvas],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Over its cell, and again at every frame the canvas draws (scrolling, resizing).
|
|
95
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the editor's row and column are the triggers.
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (!open) return;
|
|
98
|
+
const box = boxRef.current;
|
|
99
|
+
place(box, store, canvas);
|
|
100
|
+
return canvas.subscribeFrames(() => place(box, store, canvas));
|
|
101
|
+
}, [canvas, store, open, editor?.row, column]);
|
|
102
|
+
|
|
103
|
+
// An editor that opens starts with its text: the cell's (F2, a double click) or empty for the key being typed.
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
const textarea = textareaRef.current;
|
|
106
|
+
const before = shown.current;
|
|
107
|
+
shown.current = editor;
|
|
108
|
+
if (!textarea) return;
|
|
109
|
+
if (editor && !sameCell(before, editor)) {
|
|
110
|
+
textarea.value = editor.text;
|
|
111
|
+
textarea.setSelectionRange(editor.text.length, editor.text.length);
|
|
112
|
+
textarea.style.height = "";
|
|
113
|
+
if (document.activeElement !== textarea) textarea.focus({ preventScroll: true });
|
|
114
|
+
fit(textarea);
|
|
115
|
+
} else if (!editor && before && document.activeElement === textarea) {
|
|
116
|
+
// Closed: the focus goes back to the grid.
|
|
117
|
+
scrollerRef.current?.focus({ preventScroll: true });
|
|
118
|
+
}
|
|
119
|
+
}, [editor, scrollerRef]);
|
|
120
|
+
|
|
121
|
+
const change = (text: string) => {
|
|
122
|
+
if (store.getSnapshot().session.editor) store.dispatchSync({ command: "edit.change", text });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Writes the text (`edit.commit`); resolves with whether it was written. */
|
|
126
|
+
const commit = async (move: "up" | "down" | "left" | "right" | null) => {
|
|
127
|
+
const textarea = textareaRef.current;
|
|
128
|
+
const current = store.getSnapshot().session.editor;
|
|
129
|
+
if (!textarea || !current) return false;
|
|
130
|
+
setProblem(null);
|
|
131
|
+
change(textarea.value);
|
|
132
|
+
const outcome = await store.dispatch({ command: "edit.commit", move });
|
|
133
|
+
if (outcome.ok) return true;
|
|
134
|
+
const reason = outcome.error instanceof CommandError ? outcome.error.reason : null;
|
|
135
|
+
if (reason === "invalid_value") setProblem({ editor: current, text: messages.invalidValue(current.column) });
|
|
136
|
+
else if (reason === "read_only") setProblem({ editor: current, text: messages.readOnly });
|
|
137
|
+
else announce(messages.failed(outcome.error.message));
|
|
138
|
+
return false;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
142
|
+
// The grid does not handle keys typed into the editor.
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
if (!open || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
145
|
+
const textarea = event.currentTarget;
|
|
146
|
+
if (event.key === "Escape") {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
store.dispatchSync({ command: "edit.cancel" });
|
|
149
|
+
} else if (event.key === "Enter" && (event.altKey || (isApple() && event.ctrlKey))) {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
textarea.setRangeText("\n", textarea.selectionStart, textarea.selectionEnd, "end");
|
|
152
|
+
change(textarea.value);
|
|
153
|
+
fit(textarea);
|
|
154
|
+
} else if (event.key === "Enter") {
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
void commit(event.shiftKey ? "up" : "down");
|
|
157
|
+
} else if (event.key === "Tab") {
|
|
158
|
+
event.preventDefault();
|
|
159
|
+
void commit(event.shiftKey ? "left" : "right");
|
|
160
|
+
} else if (saving && keyCommand(event, isApple())?.command === "save") {
|
|
161
|
+
// The modifier and S writes the text, then saves, as a spreadsheet does.
|
|
162
|
+
if (store.getSnapshot().table?.capabilities.save !== true) return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
void commit(null).then((written) => {
|
|
165
|
+
if (written) saving.save();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onInput = (event: FormEvent<HTMLTextAreaElement>) => {
|
|
171
|
+
fit(event.currentTarget);
|
|
172
|
+
// While an IME composes, the text is not the person's yet: it is taken when the composition ends.
|
|
173
|
+
if (!(event.nativeEvent as InputEvent).isComposing) change(event.currentTarget.value);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onCompositionEnd = (event: CompositionEvent<HTMLTextAreaElement>) => change(event.currentTarget.value);
|
|
177
|
+
|
|
178
|
+
// Leaving the editor for another place of the page saves it, as a spreadsheet does.
|
|
179
|
+
const onBlur = (event: FocusEvent<HTMLTextAreaElement>) => {
|
|
180
|
+
if (open && event.relatedTarget !== null) void commit(null);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="vg-editor" ref={boxRef} data-open={open || undefined} data-invalid={error ? true : undefined}>
|
|
185
|
+
<textarea
|
|
186
|
+
ref={textareaRef}
|
|
187
|
+
className="vg-editor-input"
|
|
188
|
+
tabIndex={open ? 0 : -1}
|
|
189
|
+
aria-hidden={open ? undefined : true}
|
|
190
|
+
aria-label={editor ? messages.editing(number.format(editor.row + 1), editor.column) : undefined}
|
|
191
|
+
aria-invalid={error ? true : undefined}
|
|
192
|
+
aria-describedby={open ? (error ? `${errorId} ${hintId}` : hintId) : undefined}
|
|
193
|
+
spellCheck={false}
|
|
194
|
+
autoComplete="off"
|
|
195
|
+
autoCapitalize="off"
|
|
196
|
+
rows={1}
|
|
197
|
+
onKeyDown={onKeyDown}
|
|
198
|
+
onInput={onInput}
|
|
199
|
+
onCompositionEnd={onCompositionEnd}
|
|
200
|
+
onBlur={onBlur}
|
|
201
|
+
/>
|
|
202
|
+
{open && (
|
|
203
|
+
<span id={hintId} className="vg-sr-only">
|
|
204
|
+
{messages.editorHint}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
{open && error && (
|
|
208
|
+
<div className="vg-editor-note">
|
|
209
|
+
<span id={errorId} className="vg-editor-error" role="alert">
|
|
210
|
+
{error}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ToggleEvent, use, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { type GridCommand, isBlank, type JsonValue, joinParts, textOf } from "vastgrid-spec";
|
|
3
|
+
import { filterParts } from "../core/filters.js";
|
|
4
|
+
import { LocaleContext } from "./context.js";
|
|
5
|
+
import { useGridSelector, useGridStore } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
/** Where the cell menu opens: a cell of the view, at a point of the page. */
|
|
8
|
+
export interface CellMenuTarget {
|
|
9
|
+
row: number;
|
|
10
|
+
column: string;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Arrows, Home and End move the focus among the items of a menu (the menu pattern of the ARIA Authoring Practices). */
|
|
16
|
+
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
|
|
17
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")];
|
|
18
|
+
const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
19
|
+
const next =
|
|
20
|
+
event.key === "ArrowDown"
|
|
21
|
+
? (at + 1) % items.length
|
|
22
|
+
: event.key === "ArrowUp"
|
|
23
|
+
? (at - 1 + items.length) % items.length
|
|
24
|
+
: event.key === "Home"
|
|
25
|
+
? 0
|
|
26
|
+
: event.key === "End"
|
|
27
|
+
? items.length - 1
|
|
28
|
+
: -1;
|
|
29
|
+
if (next < 0 || items.length === 0) return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
items[next].focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The menu of a cell (a right click on it, Shift+F10 or the context menu key): copying the selection, filtering the
|
|
36
|
+
* column to the cell's value or excluding it (`filter.column`, replacing the column's conditions), showing the
|
|
37
|
+
* value in full (read again without the text limit when the engine cut it, §8.4), and the detail panel.
|
|
38
|
+
*/
|
|
39
|
+
export function CellMenu({
|
|
40
|
+
target,
|
|
41
|
+
onClose,
|
|
42
|
+
copy,
|
|
43
|
+
}: {
|
|
44
|
+
target: CellMenuTarget | null;
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
copy: () => void;
|
|
47
|
+
}) {
|
|
48
|
+
const store = useGridStore();
|
|
49
|
+
const { messages } = use(LocaleContext);
|
|
50
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
51
|
+
const [full, setFull] = useState<string | null>(null);
|
|
52
|
+
const [shown, setShown] = useState(target);
|
|
53
|
+
if (shown !== target) {
|
|
54
|
+
setShown(target);
|
|
55
|
+
setFull(null);
|
|
56
|
+
}
|
|
57
|
+
const column = useGridSelector((snapshot) =>
|
|
58
|
+
target ? snapshot.displayed.find((item) => item.column.name === target.column)?.column : undefined,
|
|
59
|
+
);
|
|
60
|
+
const filters = useGridSelector(
|
|
61
|
+
(snapshot) => snapshot.table?.capabilities.plan?.where !== false && snapshot.document.plan?.aggregate === undefined,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
const menu = ref.current;
|
|
66
|
+
if (!menu || !target) return;
|
|
67
|
+
if (!menu.matches(":popover-open")) menu.showPopover();
|
|
68
|
+
menu.querySelector<HTMLElement>("button:not(:disabled)")?.focus();
|
|
69
|
+
}, [target]);
|
|
70
|
+
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
const menu = ref.current;
|
|
73
|
+
if (!menu || !target) return;
|
|
74
|
+
const rect = menu.getBoundingClientRect();
|
|
75
|
+
menu.style.left = `${Math.max(8, Math.min(target.x, window.innerWidth - rect.width - 8))}px`;
|
|
76
|
+
menu.style.top = `${Math.max(8, Math.min(target.y, window.innerHeight - rect.height - 8))}px`;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
|
|
80
|
+
if (event.newState === "closed") onClose();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const value: JsonValue | undefined = target ? store.cell(target.row, target.column) : undefined;
|
|
84
|
+
if (!target || !column || value === undefined) {
|
|
85
|
+
return <div ref={ref} popover="auto" className="vg-menu vg-cell-menu" onToggle={onToggle} />;
|
|
86
|
+
}
|
|
87
|
+
const close = () => ref.current?.hidePopover();
|
|
88
|
+
const run = (command: GridCommand) => {
|
|
89
|
+
close();
|
|
90
|
+
void store.dispatch(command);
|
|
91
|
+
};
|
|
92
|
+
const filterTo = (exclude: boolean) => {
|
|
93
|
+
const blank = isBlank(value, column);
|
|
94
|
+
const where = joinParts(filterParts({ kind: "values", values: blank ? [] : [value], blank, exclude }, column));
|
|
95
|
+
run({ command: "filter.column", column: column.name, where: where ?? null });
|
|
96
|
+
};
|
|
97
|
+
const showFull = () => {
|
|
98
|
+
if (!store.isCut(target.row, target.column)) {
|
|
99
|
+
setFull(textOf(value, column) ?? "");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setFull(messages.loading);
|
|
103
|
+
store
|
|
104
|
+
.fullValue(target.row, target.column)
|
|
105
|
+
.then((read) => setFull(textOf(read.value, read.column) ?? ""))
|
|
106
|
+
.catch((error: unknown) => setFull(messages.failed(error instanceof Error ? error.message : String(error))));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
ref={ref}
|
|
112
|
+
popover="auto"
|
|
113
|
+
role="dialog"
|
|
114
|
+
className="vg-menu vg-cell-menu"
|
|
115
|
+
aria-label={messages.cellMenu}
|
|
116
|
+
onToggle={onToggle}
|
|
117
|
+
>
|
|
118
|
+
{full !== null ? (
|
|
119
|
+
<section aria-label={messages.fullValue}>
|
|
120
|
+
<p className="vg-filter__title">{column.name}</p>
|
|
121
|
+
<pre className="vg-cell-menu__value" tabIndex={-1}>
|
|
122
|
+
{full}
|
|
123
|
+
</pre>
|
|
124
|
+
<button type="button" className="vg-menu__item" onClick={close}>
|
|
125
|
+
{messages.close}
|
|
126
|
+
</button>
|
|
127
|
+
</section>
|
|
128
|
+
) : (
|
|
129
|
+
<div role="menu" aria-label={messages.cellMenu} onKeyDown={moveFocus}>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
role="menuitem"
|
|
133
|
+
className="vg-menu__item"
|
|
134
|
+
onClick={() => {
|
|
135
|
+
close();
|
|
136
|
+
copy();
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{messages.copy}
|
|
140
|
+
</button>
|
|
141
|
+
{filters && (
|
|
142
|
+
<>
|
|
143
|
+
<button type="button" role="menuitem" className="vg-menu__item" onClick={() => filterTo(false)}>
|
|
144
|
+
{messages.filterToValue}
|
|
145
|
+
</button>
|
|
146
|
+
<button type="button" role="menuitem" className="vg-menu__item" onClick={() => filterTo(true)}>
|
|
147
|
+
{messages.excludeValue}
|
|
148
|
+
</button>
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
<button type="button" role="menuitem" className="vg-menu__item" disabled={value === null} onClick={showFull}>
|
|
152
|
+
{messages.fullValue}
|
|
153
|
+
</button>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
role="menuitem"
|
|
157
|
+
className="vg-menu__item"
|
|
158
|
+
onClick={() => run({ command: "detail.open" })}
|
|
159
|
+
>
|
|
160
|
+
{messages.details}
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|