@ornery/ui-grid-vanilla 0.1.10 → 1.0.1
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/AGENTS.md +70 -0
- package/README.md +196 -0
- package/demo/main.ts +0 -1
- package/dist/index.d.mts +582 -55
- package/dist/index.d.ts +582 -55
- package/dist/index.js +4959 -1429
- package/dist/index.mjs +5137 -1560
- package/package.json +11 -8
- package/scss-plugin.ts +65 -0
- package/src/adopt-styles.ts +39 -0
- package/src/attribute-bridge.ts +291 -0
- package/src/auto-height.test.ts +142 -0
- package/src/auto-resize.test.ts +207 -0
- package/src/cell-nav.test.ts +262 -0
- package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
- package/src/components/grid-body-cell/index.ts +1 -0
- package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
- package/src/components/grid-cell-editor/index.ts +1 -0
- package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
- package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
- package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
- package/src/components/grid-filter-cell/index.ts +1 -0
- package/src/components/grid-group-row/grid-group-row.scss +24 -0
- package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
- package/src/components/grid-group-row/grid-group-row.ts +94 -0
- package/src/components/grid-group-row/index.ts +1 -0
- package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
- package/src/components/grid-header-cell/index.ts +1 -0
- package/src/components/grid-pagination/grid-pagination.scss +39 -0
- package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
- package/src/components/grid-pagination/grid-pagination.ts +204 -0
- package/src/components/grid-pagination/index.ts +1 -0
- package/src/components/grid-template/grid-template.ts +166 -0
- package/src/components/grid-template/index.ts +1 -0
- package/src/events.ts +824 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +379 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1532 -21
- package/src/icons.ts +88 -0
- package/src/importer.test.ts +147 -0
- package/src/index.integration.test.ts +2 -2
- package/src/index.ts +9 -0
- package/src/infinite-scroll.test.ts +220 -0
- package/src/keyboard-navigation.test.ts +579 -0
- package/src/patch.ts +722 -0
- package/src/render.ts +465 -0
- package/src/row-edit.test.ts +191 -0
- package/src/row-selection.test.ts +550 -0
- package/src/save-state.test.ts +235 -0
- package/src/scss.d.ts +4 -0
- package/src/templates/index.ts +62 -0
- package/src/test-setup.ts +23 -0
- package/src/ui-grid-empty.template.ts +23 -0
- package/src/ui-grid-shell.scss +25 -0
- package/src/ui-grid-shell.template.ts +45 -0
- package/src/ui-grid-standalone.element.ts +751 -1112
- package/src/utils/cell-class.ts +70 -0
- package/src/utils/display-items.ts +15 -0
- package/src/utils/dom.ts +64 -0
- package/src/validate.test.ts +170 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +10 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { clearRustWasmGridEngine } from '@ornery/ui-grid-core';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
mountVanillaUiGrid,
|
|
7
|
+
type GridOptions,
|
|
8
|
+
type UiGridApi,
|
|
9
|
+
type VanillaUiGridElement,
|
|
10
|
+
} from './index';
|
|
11
|
+
|
|
12
|
+
async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
|
|
13
|
+
const startedAt = Date.now();
|
|
14
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
15
|
+
const value = resolve();
|
|
16
|
+
if (value) return value;
|
|
17
|
+
await new Promise((r) => window.setTimeout(r, 10));
|
|
18
|
+
}
|
|
19
|
+
throw new Error('Timed out waiting for expected state');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TAG = 'ui-grid-element-vanilla-exporter-test';
|
|
23
|
+
|
|
24
|
+
interface Row extends Record<string, unknown> {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
revenue: number;
|
|
28
|
+
status: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
|
|
32
|
+
let capturedApi: UiGridApi | undefined;
|
|
33
|
+
const data: Row[] = [
|
|
34
|
+
{ id: 'r1', name: 'Alpha', revenue: 100, status: 'Active' },
|
|
35
|
+
{ id: 'r2', name: 'Beta, Inc.', revenue: 200, status: 'Pilot' },
|
|
36
|
+
{ id: 'r3', name: 'Gamma', revenue: 300, status: 'Draft' },
|
|
37
|
+
];
|
|
38
|
+
const options: GridOptions = {
|
|
39
|
+
id: 'exporter-grid',
|
|
40
|
+
data,
|
|
41
|
+
columnDefs: [
|
|
42
|
+
{ name: 'name', displayName: 'Name' },
|
|
43
|
+
{ name: 'revenue', displayName: 'Revenue' },
|
|
44
|
+
{ name: 'status', displayName: 'Status' },
|
|
45
|
+
],
|
|
46
|
+
rowIdentity: (entity) => String((entity as Row).id),
|
|
47
|
+
enableRowSelection: true,
|
|
48
|
+
onRegisterApi: (api) => {
|
|
49
|
+
capturedApi = api as UiGridApi;
|
|
50
|
+
},
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
54
|
+
return options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getApi(options: GridOptions): UiGridApi {
|
|
58
|
+
const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
59
|
+
if (!api) throw new Error('Grid API was never registered');
|
|
60
|
+
return api;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mountGrid(options: GridOptions): Promise<{
|
|
64
|
+
grid: VanillaUiGridElement;
|
|
65
|
+
shadow: ShadowRoot;
|
|
66
|
+
}> {
|
|
67
|
+
const target = document.getElementById('app')!;
|
|
68
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
69
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
70
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"]'));
|
|
71
|
+
return { grid, shadow };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('vanilla grid exporter', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
clearRustWasmGridEngine();
|
|
81
|
+
document.body.innerHTML = '';
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('exposes exporter.buildCsv on the public api', async () => {
|
|
85
|
+
const options = baseOptions();
|
|
86
|
+
await mountGrid(options);
|
|
87
|
+
const api = getApi(options);
|
|
88
|
+
const csv = api.exporter.buildCsv('visible', 'visible');
|
|
89
|
+
expect(csv.split('\n')).toEqual([
|
|
90
|
+
'Name,Revenue,Status',
|
|
91
|
+
'Alpha,100,Active',
|
|
92
|
+
'"Beta, Inc.",200,Pilot',
|
|
93
|
+
'Gamma,300,Draft',
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('respects the row-type argument (selected rows only)', async () => {
|
|
98
|
+
const options = baseOptions();
|
|
99
|
+
await mountGrid(options);
|
|
100
|
+
const api = getApi(options);
|
|
101
|
+
api.selection.selectRow(options.data[0]! as unknown as Record<string, unknown>);
|
|
102
|
+
api.selection.selectRow(options.data[2]! as unknown as Record<string, unknown>);
|
|
103
|
+
const csv = api.exporter.buildCsv('selected');
|
|
104
|
+
expect(csv.split('\n').slice(1)).toEqual(['Alpha,100,Active', 'Gamma,300,Draft']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('skips suppressed columns from setOptions override', async () => {
|
|
108
|
+
const options = baseOptions();
|
|
109
|
+
await mountGrid(options);
|
|
110
|
+
const api = getApi(options);
|
|
111
|
+
api.exporter.setOptions({ suppressColumns: ['revenue'] });
|
|
112
|
+
const csv = api.exporter.buildCsv();
|
|
113
|
+
expect(csv.split('\n')[0]).toBe('Name,Status');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('honors exporterCsvColumnSeparator from grid options', async () => {
|
|
117
|
+
const options = baseOptions({ exporterCsvColumnSeparator: ';' });
|
|
118
|
+
await mountGrid(options);
|
|
119
|
+
const api = getApi(options);
|
|
120
|
+
const csv = api.exporter.buildCsv();
|
|
121
|
+
expect(csv.split('\n')[0]).toBe('Name;Revenue;Status');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('includes hidden columns when colType is "all"', async () => {
|
|
125
|
+
const options = baseOptions({
|
|
126
|
+
columnDefs: [
|
|
127
|
+
{ name: 'name', displayName: 'Name' },
|
|
128
|
+
{ name: 'revenue', displayName: 'Revenue', visible: false },
|
|
129
|
+
{ name: 'status', displayName: 'Status' },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
await mountGrid(options);
|
|
133
|
+
const api = getApi(options);
|
|
134
|
+
expect(api.exporter.buildCsv('visible', 'visible').split('\n')[0]).toBe('Name,Status');
|
|
135
|
+
expect(api.exporter.buildCsv('visible', 'all').split('\n')[0]).toBe('Name,Revenue,Status');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns an ExcelBuilder-ready sheet data grid via exporter.buildExcelSheetData', async () => {
|
|
139
|
+
const options = baseOptions();
|
|
140
|
+
await mountGrid(options);
|
|
141
|
+
const api = getApi(options);
|
|
142
|
+
const sheet = api.exporter.buildExcelSheetData('visible', 'visible');
|
|
143
|
+
// Header row + 3 data rows.
|
|
144
|
+
expect(sheet.length).toBe(4);
|
|
145
|
+
expect(sheet[0]!.map((c) => c.value)).toEqual(['Name', 'Revenue', 'Status']);
|
|
146
|
+
expect(sheet[1]!.map((c) => c.value)).toEqual(['Alpha', 100, 'Active']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns a pdfMake-ready doc definition via exporter.buildPdfDocDefinition', async () => {
|
|
150
|
+
const options = baseOptions();
|
|
151
|
+
await mountGrid(options);
|
|
152
|
+
const api = getApi(options);
|
|
153
|
+
const doc = api.exporter.buildPdfDocDefinition('visible', 'visible');
|
|
154
|
+
expect(doc.pageOrientation).toBe('landscape');
|
|
155
|
+
expect(doc.content[0]!.table.headerRows).toBe(1);
|
|
156
|
+
// Header row + 3 data rows.
|
|
157
|
+
expect(doc.content[0]!.table.body.length).toBe(4);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('exposes menu items via exporter.getMenuItems with locale-driven titles', async () => {
|
|
161
|
+
const options = baseOptions({
|
|
162
|
+
labels: { exporterAllAsCsv: 'Export everything as csv' },
|
|
163
|
+
});
|
|
164
|
+
await mountGrid(options);
|
|
165
|
+
const api = getApi(options);
|
|
166
|
+
const items = api.exporter.getMenuItems();
|
|
167
|
+
const allAsCsv = items.find((i) => i.title === 'Export everything as csv');
|
|
168
|
+
expect(allAsCsv).toBeDefined();
|
|
169
|
+
expect(allAsCsv!.shown()).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('hides the "selected" menu item when no rows are selected', async () => {
|
|
173
|
+
const options = baseOptions();
|
|
174
|
+
await mountGrid(options);
|
|
175
|
+
const api = getApi(options);
|
|
176
|
+
const items = api.exporter.getMenuItems();
|
|
177
|
+
const selectedItems = items.filter((i) => /selected/i.test(i.title));
|
|
178
|
+
// Selected rows menu entry stays hidden because no rows are selected.
|
|
179
|
+
expect(selectedItems.every((i) => i.shown() === false)).toBe(true);
|
|
180
|
+
|
|
181
|
+
api.selection.selectRow(options.data[0]! as unknown as Record<string, unknown>);
|
|
182
|
+
const itemsAfter = api.exporter.getMenuItems();
|
|
183
|
+
const selectedAfter = itemsAfter.filter((i) => /selected/i.test(i.title));
|
|
184
|
+
expect(selectedAfter.every((i) => i.shown() === true)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard navigation + focused-cell management.
|
|
3
|
+
*
|
|
4
|
+
* `moveGridFocus` is the central router: arrow keys, Tab/Enter inside an
|
|
5
|
+
* edit session, Home/End, Ctrl+Home/End all funnel through it. It knows
|
|
6
|
+
* how to walk the visible-row list (skipping group headers + expandable
|
|
7
|
+
* rows), moves the `cell-focused` decoration, nudges virtualization to
|
|
8
|
+
* bring off-screen targets into view, and DOM-focuses the target cell.
|
|
9
|
+
*
|
|
10
|
+
* Each function takes the element as its first argument and reads its
|
|
11
|
+
* internal state (snapshot / controller / focusedCell / shadowRoot)
|
|
12
|
+
* directly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
findNextGridCell,
|
|
17
|
+
type GridColumnDef,
|
|
18
|
+
type GridRow,
|
|
19
|
+
} from '@ornery/ui-grid-core';
|
|
20
|
+
import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
|
|
21
|
+
import { cssEscape } from './utils/cell-class';
|
|
22
|
+
import { isRowItem } from './utils/display-items';
|
|
23
|
+
|
|
24
|
+
export type GridMoveDirection =
|
|
25
|
+
| 'left'
|
|
26
|
+
| 'right'
|
|
27
|
+
| 'up'
|
|
28
|
+
| 'down'
|
|
29
|
+
| 'rowStart'
|
|
30
|
+
| 'rowEnd'
|
|
31
|
+
| 'top'
|
|
32
|
+
| 'bottom';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Commit the in-flight edit, then move keyboard focus relative to the
|
|
36
|
+
* committed cell. Splits commit + focus across two paint frames: commit
|
|
37
|
+
* runs synchronously (editor unmounts, cell re-renders with the new
|
|
38
|
+
* value), then focus moves to the adjacent cell in a microtask so the
|
|
39
|
+
* blur handler's re-entry guard doesn't race the focus attempt.
|
|
40
|
+
*
|
|
41
|
+
* If the source cell was being edited, the destination cell also enters
|
|
42
|
+
* edit mode provided its column is editable — Tab/Enter inside an edit
|
|
43
|
+
* session stays continuous. Arrow nav outside an edit session does NOT
|
|
44
|
+
* auto-enter edit.
|
|
45
|
+
*/
|
|
46
|
+
export function commitAndMove(
|
|
47
|
+
el: UiGridStandaloneElement,
|
|
48
|
+
fromRowId: string | null,
|
|
49
|
+
fromColumnName: string | null,
|
|
50
|
+
direction: 'left' | 'right' | 'up' | 'down',
|
|
51
|
+
): void {
|
|
52
|
+
if (!el.controller || !fromRowId || !fromColumnName) return;
|
|
53
|
+
el.controller.commitCellEdit();
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
moveGridFocus(el, direction, fromRowId, fromColumnName, { resumeEdit: true });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Move focus relative to `(rowId, columnName)`. Uses the snapshot's
|
|
61
|
+
* display-items order so ArrowDown skips over group headers + expandable
|
|
62
|
+
* rows to whatever row comes visually next. `top` / `bottom` jump to the
|
|
63
|
+
* first/last visible row; `rowStart` / `rowEnd` jump to the first/last
|
|
64
|
+
* column of the current row.
|
|
65
|
+
*/
|
|
66
|
+
export function moveGridFocus(
|
|
67
|
+
el: UiGridStandaloneElement,
|
|
68
|
+
direction: GridMoveDirection,
|
|
69
|
+
rowId: string | null,
|
|
70
|
+
columnName: string | null,
|
|
71
|
+
opts: { resumeEdit?: boolean } = {},
|
|
72
|
+
): void {
|
|
73
|
+
if (!el.snapshot || !el.controller || !rowId || !columnName) return;
|
|
74
|
+
|
|
75
|
+
const rows: GridRow[] = [];
|
|
76
|
+
for (const item of el.snapshot.pipeline.displayItems) {
|
|
77
|
+
if (isRowItem(item)) rows.push(item.row);
|
|
78
|
+
}
|
|
79
|
+
const columns = el.snapshot.visibleColumns;
|
|
80
|
+
if (rows.length === 0 || columns.length === 0) return;
|
|
81
|
+
|
|
82
|
+
let nextRowId = rowId;
|
|
83
|
+
let nextColumnName = columnName;
|
|
84
|
+
let nextRow: GridRow | undefined;
|
|
85
|
+
let nextColumn: GridColumnDef | undefined;
|
|
86
|
+
|
|
87
|
+
if (direction === 'top' || direction === 'bottom') {
|
|
88
|
+
const row = rows[direction === 'top' ? 0 : rows.length - 1];
|
|
89
|
+
if (!row) return;
|
|
90
|
+
nextRow = row;
|
|
91
|
+
nextColumn = columns.find((c) => c.name === columnName);
|
|
92
|
+
nextRowId = row.id;
|
|
93
|
+
} else if (direction === 'rowStart' || direction === 'rowEnd') {
|
|
94
|
+
const col = columns[direction === 'rowStart' ? 0 : columns.length - 1];
|
|
95
|
+
if (!col) return;
|
|
96
|
+
nextColumn = col;
|
|
97
|
+
nextRow = rows.find((r) => r.id === rowId);
|
|
98
|
+
nextColumnName = col.name;
|
|
99
|
+
} else {
|
|
100
|
+
const next = findNextGridCell({ rows, columns, rowId, columnName, direction });
|
|
101
|
+
if (!next) return;
|
|
102
|
+
nextRow = next.row;
|
|
103
|
+
nextColumn = next.column;
|
|
104
|
+
nextRowId = next.row.id;
|
|
105
|
+
nextColumnName = next.column.name;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const previous = el.focusedCell;
|
|
109
|
+
el.focusedCell = { rowId: nextRowId, columnName: nextColumnName };
|
|
110
|
+
// Move the `cell-focused` decoration immediately so the selection
|
|
111
|
+
// indicator tracks keyboard nav even though we don't re-render the whole
|
|
112
|
+
// grid on every arrow press — only the two affected cells are touched.
|
|
113
|
+
applyFocusedCellClass(el, previous, el.focusedCell);
|
|
114
|
+
scrollFocusedRowIntoView(el, nextRowId);
|
|
115
|
+
// Raise cellNav.navigate for consumers that wired a listener (ports the
|
|
116
|
+
// old gridApi.cellNav.on.navigate event).
|
|
117
|
+
el.controller.setCellNavFocus(nextRowId, nextColumnName);
|
|
118
|
+
|
|
119
|
+
// When moving out of an edit session (Tab/Enter in editor), auto-open
|
|
120
|
+
// the next cell's editor if that cell is editable. Non-edit nav (plain
|
|
121
|
+
// arrow keys on a non-editing cell) never opens the editor.
|
|
122
|
+
if (
|
|
123
|
+
opts.resumeEdit &&
|
|
124
|
+
nextRow &&
|
|
125
|
+
nextColumn &&
|
|
126
|
+
el.controller.isCellEditable(nextRow, nextColumn)
|
|
127
|
+
) {
|
|
128
|
+
el.controller.beginCellEdit(nextRowId, nextColumnName);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
focusCellElement(el, nextRowId, nextColumnName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ensure the row for `rowId` is inside the virtualization window before
|
|
137
|
+
* focus moves there. When virtualization is on, a distant row isn't yet
|
|
138
|
+
* rendered into the DOM — scroll to bring it in, let the virtual-body
|
|
139
|
+
* rebuild on the next frame, then `focusCellElement`'s retry picks it up.
|
|
140
|
+
*/
|
|
141
|
+
export function scrollFocusedRowIntoView(el: UiGridStandaloneElement, rowId: string): void {
|
|
142
|
+
const snapshot = el.snapshot;
|
|
143
|
+
if (!snapshot) return;
|
|
144
|
+
const index = snapshot.pipeline.displayItems.findIndex(
|
|
145
|
+
(item) => 'row' in item && item.row && (item.row as { id: string }).id === rowId,
|
|
146
|
+
);
|
|
147
|
+
if (index < 0) return;
|
|
148
|
+
const bodyViewport = el.shadowRoot?.querySelector<HTMLElement>('.grid-body-viewport');
|
|
149
|
+
if (!bodyViewport) return;
|
|
150
|
+
const rowTop = index * snapshot.rowSize;
|
|
151
|
+
const rowBottom = rowTop + snapshot.rowSize;
|
|
152
|
+
const viewportTop = bodyViewport.scrollTop;
|
|
153
|
+
const viewportHeight = bodyViewport.clientHeight;
|
|
154
|
+
if (rowTop < viewportTop) {
|
|
155
|
+
bodyViewport.scrollTop = rowTop;
|
|
156
|
+
} else if (rowBottom > viewportTop + viewportHeight) {
|
|
157
|
+
bodyViewport.scrollTop = rowBottom - viewportHeight;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Toggle the `cell-focused` class between the previous and next cells.
|
|
163
|
+
* The grid shell doesn't re-render on every arrow press — we hand-mutate
|
|
164
|
+
* the two affected cells instead to avoid touching the rest of the DOM.
|
|
165
|
+
*/
|
|
166
|
+
export function applyFocusedCellClass(
|
|
167
|
+
el: UiGridStandaloneElement,
|
|
168
|
+
previous: { rowId: string; columnName: string } | null,
|
|
169
|
+
next: { rowId: string; columnName: string } | null,
|
|
170
|
+
): void {
|
|
171
|
+
const root = el.shadowRoot;
|
|
172
|
+
if (!root) return;
|
|
173
|
+
if (previous && (previous.rowId !== next?.rowId || previous.columnName !== next?.columnName)) {
|
|
174
|
+
const prevEl = root.querySelector<HTMLElement>(
|
|
175
|
+
`.body-cell[data-row="${cssEscape(previous.rowId)}"][data-column="${cssEscape(previous.columnName)}"]`,
|
|
176
|
+
);
|
|
177
|
+
if (prevEl) {
|
|
178
|
+
prevEl.classList.remove('cell-focused');
|
|
179
|
+
prevEl.setAttribute('data-focused', 'false');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (next) {
|
|
183
|
+
const nextEl = root.querySelector<HTMLElement>(
|
|
184
|
+
`.body-cell[data-row="${cssEscape(next.rowId)}"][data-column="${cssEscape(next.columnName)}"]`,
|
|
185
|
+
);
|
|
186
|
+
if (nextEl) {
|
|
187
|
+
nextEl.classList.add('cell-focused');
|
|
188
|
+
nextEl.setAttribute('data-focused', 'true');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* DOM-focus the body cell matching the given row/column. Retries across
|
|
195
|
+
* two animation frames so a scroll-triggered virtual-body rebuild has
|
|
196
|
+
* time to bring the target row into the DOM before we give up.
|
|
197
|
+
*/
|
|
198
|
+
export function focusCellElement(
|
|
199
|
+
el: UiGridStandaloneElement,
|
|
200
|
+
rowId: string | null,
|
|
201
|
+
columnName: string | null,
|
|
202
|
+
): void {
|
|
203
|
+
if (!rowId || !columnName) return;
|
|
204
|
+
const root = el.shadowRoot;
|
|
205
|
+
if (!root) return;
|
|
206
|
+
const selector = `.body-cell[data-row="${cssEscape(rowId)}"][data-column="${cssEscape(columnName)}"]`;
|
|
207
|
+
const attempt = (retriesLeft: number): void => {
|
|
208
|
+
const target = root.querySelector<HTMLElement>(selector);
|
|
209
|
+
if (!target) {
|
|
210
|
+
if (retriesLeft > 0) requestAnimationFrame(() => attempt(retriesLeft - 1));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
target.focus({ preventScroll: false });
|
|
215
|
+
} catch {
|
|
216
|
+
target.focus();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
attempt(2);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Dispatch a plain click on a body cell through the selection module.
|
|
224
|
+
* Mirrors the old `ui.grid.selection.uiGridCell` directive: shift-click
|
|
225
|
+
* ranges, ctrl/meta toggles one, plain click respects
|
|
226
|
+
* `enableFullRowSelection` + `enableSelectRowOnFocus` +
|
|
227
|
+
* `modifierKeysToMultiSelect`.
|
|
228
|
+
*/
|
|
229
|
+
export function handleRowSelectionClick(
|
|
230
|
+
el: UiGridStandaloneElement,
|
|
231
|
+
rowId: string,
|
|
232
|
+
columnName: string,
|
|
233
|
+
event: Event,
|
|
234
|
+
): void {
|
|
235
|
+
const controller = el.controller;
|
|
236
|
+
if (!controller) return;
|
|
237
|
+
const resolved = controller.getResolvedSelectionOptions();
|
|
238
|
+
if (!resolved.enableRowSelection) return;
|
|
239
|
+
// The checkbox column has its own click path.
|
|
240
|
+
if (columnName === 'selectionRowHeaderCol') return;
|
|
241
|
+
if (!resolved.enableFullRowSelection) {
|
|
242
|
+
// Full-row selection is off — only the checkbox column selects, but
|
|
243
|
+
// focus-row-changed should still fire.
|
|
244
|
+
const row = controller.findRowByIdPublic(rowId);
|
|
245
|
+
if (row) {
|
|
246
|
+
controller.setRowFocused(rowId, true, event);
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const row = controller.findRowByIdPublic(rowId);
|
|
251
|
+
if (!row) return;
|
|
252
|
+
const mouseEvent = event as MouseEvent;
|
|
253
|
+
if (mouseEvent.shiftKey) {
|
|
254
|
+
controller.shiftSelectRow(row.entity, event);
|
|
255
|
+
} else if (mouseEvent.ctrlKey || mouseEvent.metaKey) {
|
|
256
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
257
|
+
} else if (resolved.enableSelectRowOnFocus) {
|
|
258
|
+
if (resolved.multiSelect && !resolved.modifierKeysToMultiSelect) {
|
|
259
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
260
|
+
} else {
|
|
261
|
+
controller.setMultiSelect(false);
|
|
262
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
263
|
+
controller.setMultiSelect(resolved.multiSelect);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
controller.setRowFocused(rowId, true, event);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Click handler for the row-header checkbox column. Differs from the
|
|
271
|
+
* full-row click only in that shift/ctrl semantics mirror the old
|
|
272
|
+
* selection module (shift-select pulls from `lastSelectedRow` regardless
|
|
273
|
+
* of modifier).
|
|
274
|
+
*/
|
|
275
|
+
export function handleRowHeaderCheckboxClick(
|
|
276
|
+
el: UiGridStandaloneElement,
|
|
277
|
+
rowId: string,
|
|
278
|
+
event: Event,
|
|
279
|
+
): void {
|
|
280
|
+
const controller = el.controller;
|
|
281
|
+
if (!controller) return;
|
|
282
|
+
const resolved = controller.getResolvedSelectionOptions();
|
|
283
|
+
if (!resolved.enableRowSelection) return;
|
|
284
|
+
const row = controller.findRowByIdPublic(rowId);
|
|
285
|
+
if (!row) return;
|
|
286
|
+
const mouseEvent = event as MouseEvent;
|
|
287
|
+
if (mouseEvent.shiftKey) {
|
|
288
|
+
controller.shiftSelectRow(row.entity, event);
|
|
289
|
+
} else if (mouseEvent.ctrlKey || mouseEvent.metaKey) {
|
|
290
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
291
|
+
} else {
|
|
292
|
+
if (resolved.multiSelect && !resolved.modifierKeysToMultiSelect) {
|
|
293
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
294
|
+
} else {
|
|
295
|
+
controller.setMultiSelect(false);
|
|
296
|
+
controller.toggleRowSelectionByEntity(row.entity, event);
|
|
297
|
+
controller.setMultiSelect(resolved.multiSelect);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (resolved.enableFocusRowOnRowHeaderClick) {
|
|
301
|
+
controller.setRowFocused(rowId, true, event);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Does a keydown event match a cellNav key-override descriptor?
|
|
307
|
+
* Undefined fields on the override are treated as wildcards. Used by the
|
|
308
|
+
* event bus to decide whether a key should be handled internally or
|
|
309
|
+
* forwarded to the consumer via `viewPortKeyDown`.
|
|
310
|
+
*/
|
|
311
|
+
export function matchesKeyOverride(
|
|
312
|
+
override: {
|
|
313
|
+
keyCode?: number;
|
|
314
|
+
key?: string;
|
|
315
|
+
shiftKey?: boolean;
|
|
316
|
+
ctrlKey?: boolean;
|
|
317
|
+
altKey?: boolean;
|
|
318
|
+
metaKey?: boolean;
|
|
319
|
+
},
|
|
320
|
+
event: KeyboardEvent,
|
|
321
|
+
): boolean {
|
|
322
|
+
if (override.keyCode !== undefined && override.keyCode !== event.keyCode) return false;
|
|
323
|
+
if (override.key !== undefined && override.key !== event.key) return false;
|
|
324
|
+
if (override.shiftKey !== undefined && override.shiftKey !== event.shiftKey) return false;
|
|
325
|
+
if (override.ctrlKey !== undefined && override.ctrlKey !== event.ctrlKey) return false;
|
|
326
|
+
if (override.altKey !== undefined && override.altKey !== event.altKey) return false;
|
|
327
|
+
if (override.metaKey !== undefined && override.metaKey !== event.metaKey) return false;
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Measure the natural content width of a column by cloning visible cells
|
|
333
|
+
* into a hidden max-content container. This avoids measuring the CSS Grid
|
|
334
|
+
* track width (which just reflects the current column size) and instead
|
|
335
|
+
* returns the width the content actually needs.
|
|
336
|
+
*/
|
|
337
|
+
export function measureAutoColumnWidth(el: UiGridStandaloneElement, columnName: string): number {
|
|
338
|
+
const root = el.shadowRoot;
|
|
339
|
+
if (root == null) return 176;
|
|
340
|
+
const escaped = cssEscape(columnName);
|
|
341
|
+
|
|
342
|
+
const probe = document.createElement('div');
|
|
343
|
+
probe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:max-content;visibility:hidden;';
|
|
344
|
+
root.appendChild(probe);
|
|
345
|
+
|
|
346
|
+
let maxWidth = 0;
|
|
347
|
+
|
|
348
|
+
// Header cell: clone and measure at natural width.
|
|
349
|
+
for (const header of root.querySelectorAll<HTMLElement>(`.header-cell[data-column="${escaped}"]`)) {
|
|
350
|
+
const clone = header.cloneNode(true) as HTMLElement;
|
|
351
|
+
clone.style.cssText = 'display:grid;grid-template-columns:auto auto;gap:inherit;padding:inherit;width:max-content;';
|
|
352
|
+
probe.appendChild(clone);
|
|
353
|
+
maxWidth = Math.max(maxWidth, clone.scrollWidth);
|
|
354
|
+
probe.removeChild(clone);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Filter cell: both custom element and class-based fallback.
|
|
358
|
+
for (const filter of root.querySelectorAll<HTMLElement>(`ui-grid-filter-cell[data-column="${escaped}"], .filter-cell[data-column="${escaped}"]`)) {
|
|
359
|
+
const clone = filter.cloneNode(true) as HTMLElement;
|
|
360
|
+
clone.style.cssText = 'display:block;width:max-content;';
|
|
361
|
+
probe.appendChild(clone);
|
|
362
|
+
maxWidth = Math.max(maxWidth, clone.scrollWidth);
|
|
363
|
+
probe.removeChild(clone);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Body cells: clone .cell-shell content and measure.
|
|
367
|
+
for (const cell of root.querySelectorAll<HTMLElement>(`.body-cell[data-column="${escaped}"]`)) {
|
|
368
|
+
const shell = cell.querySelector<HTMLElement>('.cell-shell');
|
|
369
|
+
if (!shell) continue;
|
|
370
|
+
const clone = shell.cloneNode(true) as HTMLElement;
|
|
371
|
+
clone.style.cssText = 'display:flex;width:max-content;';
|
|
372
|
+
probe.appendChild(clone);
|
|
373
|
+
maxWidth = Math.max(maxWidth, clone.scrollWidth);
|
|
374
|
+
probe.removeChild(clone);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
root.removeChild(probe);
|
|
378
|
+
return Math.max(88, maxWidth + 12);
|
|
379
|
+
}
|