@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,207 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit tests for auto-height adjustment and ResizeObserver (enableAutoResize).
|
|
5
|
+
*
|
|
6
|
+
* These tests exercise the logic directly on the UiGridStandaloneElement class.
|
|
7
|
+
* Because jsdom partially supports custom elements (the class must extend
|
|
8
|
+
* HTMLElement at import time), we define a minimal mock before importing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Provide a mock ResizeObserver before the element module loads.
|
|
12
|
+
const mockResizeObserverDisconnect = vi.fn();
|
|
13
|
+
let resizeObserverCallback: ResizeObserverCallback | null = null;
|
|
14
|
+
class MockResizeObserver {
|
|
15
|
+
callback: ResizeObserverCallback;
|
|
16
|
+
constructor(callback: ResizeObserverCallback) {
|
|
17
|
+
this.callback = callback;
|
|
18
|
+
resizeObserverCallback = callback;
|
|
19
|
+
}
|
|
20
|
+
observe = vi.fn();
|
|
21
|
+
unobserve = vi.fn();
|
|
22
|
+
disconnect = mockResizeObserverDisconnect;
|
|
23
|
+
}
|
|
24
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
|
25
|
+
|
|
26
|
+
// requestAnimationFrame / cancelAnimationFrame for debounce tests.
|
|
27
|
+
let rafId = 0;
|
|
28
|
+
const rafCallbacks = new Map<number, FrameRequestCallback>();
|
|
29
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
30
|
+
const id = ++rafId;
|
|
31
|
+
rafCallbacks.set(id, cb);
|
|
32
|
+
return id;
|
|
33
|
+
});
|
|
34
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
35
|
+
rafCallbacks.delete(id);
|
|
36
|
+
});
|
|
37
|
+
function flushRaf() {
|
|
38
|
+
for (const [id, cb] of rafCallbacks) {
|
|
39
|
+
rafCallbacks.delete(id);
|
|
40
|
+
cb(performance.now());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
import { defineStandaloneUiGridElement, UiGridStandaloneElement } from './ui-grid-standalone.element';
|
|
45
|
+
|
|
46
|
+
describe('autoAdjustHeight', () => {
|
|
47
|
+
let element: UiGridStandaloneElement;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
document.body.innerHTML = '';
|
|
51
|
+
await defineStandaloneUiGridElement('ui-grid-element-resize-test');
|
|
52
|
+
element = document.createElement('ui-grid-element-resize-test') as unknown as UiGridStandaloneElement;
|
|
53
|
+
document.body.appendChild(element);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sets host height to accommodate minRowsToShow + header + filter + pagination', () => {
|
|
57
|
+
element.options = {
|
|
58
|
+
id: 'test-grid',
|
|
59
|
+
data: [{ name: 'Alice' }],
|
|
60
|
+
columnDefs: [{ name: 'name' }],
|
|
61
|
+
enableFiltering: true,
|
|
62
|
+
enablePagination: true,
|
|
63
|
+
enablePaginationControls: true,
|
|
64
|
+
minRowsToShow: 5,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// After setting options, the element should have rendered and autoAdjustHeight
|
|
68
|
+
// should have set a minimum height. Since clientHeight in jsdom defaults to 0,
|
|
69
|
+
// the element's style.height should be set.
|
|
70
|
+
const height = parseInt(element.style.height, 10);
|
|
71
|
+
expect(height).toBeGreaterThan(0);
|
|
72
|
+
|
|
73
|
+
// The minimum should include header + (5 rows * rowHeight).
|
|
74
|
+
// Default rowHeight is 44 (from snapshot.rowSize), header ~50.
|
|
75
|
+
// Exact values depend on render, but it must be >= 5*44 + 50 = 270.
|
|
76
|
+
expect(height).toBeGreaterThanOrEqual(270);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not set height when enableMinHeightCheck is false', () => {
|
|
80
|
+
element.options = {
|
|
81
|
+
id: 'test-grid',
|
|
82
|
+
data: [{ name: 'Alice' }],
|
|
83
|
+
columnDefs: [{ name: 'name' }],
|
|
84
|
+
enableMinHeightCheck: false,
|
|
85
|
+
minRowsToShow: 5,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// autoAdjustHeight bails when enableMinHeightCheck is false.
|
|
89
|
+
// The height should not have been imperatively set (remains '' or unset).
|
|
90
|
+
expect(element.style.height).toBe('');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('defaults minRowsToShow to 10 when not specified', () => {
|
|
94
|
+
element.options = {
|
|
95
|
+
id: 'test-grid',
|
|
96
|
+
data: [{ name: 'Alice' }],
|
|
97
|
+
columnDefs: [{ name: 'name' }],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const height = parseInt(element.style.height, 10);
|
|
101
|
+
// Default: 10 rows * 44px + header(50) = 490 minimum
|
|
102
|
+
expect(height).toBeGreaterThanOrEqual(490);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('enableAutoResize (ResizeObserver)', () => {
|
|
107
|
+
let element: UiGridStandaloneElement;
|
|
108
|
+
|
|
109
|
+
beforeEach(async () => {
|
|
110
|
+
document.body.innerHTML = '';
|
|
111
|
+
resizeObserverCallback = null;
|
|
112
|
+
mockResizeObserverDisconnect.mockClear();
|
|
113
|
+
rafCallbacks.clear();
|
|
114
|
+
|
|
115
|
+
await defineStandaloneUiGridElement('ui-grid-element-resize-test');
|
|
116
|
+
element = document.createElement('ui-grid-element-resize-test') as unknown as UiGridStandaloneElement;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('creates a ResizeObserver on connectedCallback', () => {
|
|
120
|
+
document.body.appendChild(element);
|
|
121
|
+
expect(element.autoResizeObserver).not.toBeNull();
|
|
122
|
+
expect(resizeObserverCallback).not.toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('disconnects the ResizeObserver on disconnectedCallback', () => {
|
|
126
|
+
document.body.appendChild(element);
|
|
127
|
+
expect(element.autoResizeObserver).not.toBeNull();
|
|
128
|
+
|
|
129
|
+
element.remove();
|
|
130
|
+
expect(mockResizeObserverDisconnect).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(element.autoResizeObserver).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('debounces resize events via requestAnimationFrame', () => {
|
|
135
|
+
document.body.appendChild(element);
|
|
136
|
+
element.options = {
|
|
137
|
+
id: 'test-grid',
|
|
138
|
+
data: [{ name: 'Alice' }],
|
|
139
|
+
columnDefs: [{ name: 'name' }],
|
|
140
|
+
enableMinHeightCheck: false,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const renderSpy = vi.spyOn(element as any, 'render');
|
|
144
|
+
renderSpy.mockClear();
|
|
145
|
+
|
|
146
|
+
// Simulate multiple rapid resize events
|
|
147
|
+
const entries = [{ contentRect: { width: 500, height: 400 } }] as unknown as ResizeObserverEntry[];
|
|
148
|
+
resizeObserverCallback!(entries, element.autoResizeObserver as unknown as ResizeObserver);
|
|
149
|
+
resizeObserverCallback!(entries, element.autoResizeObserver as unknown as ResizeObserver);
|
|
150
|
+
resizeObserverCallback!(entries, element.autoResizeObserver as unknown as ResizeObserver);
|
|
151
|
+
|
|
152
|
+
// render should not have been called yet (debounced)
|
|
153
|
+
expect(renderSpy).not.toHaveBeenCalled();
|
|
154
|
+
|
|
155
|
+
// Flush the rAF
|
|
156
|
+
flushRaf();
|
|
157
|
+
|
|
158
|
+
// Only one render call from the debounced resize (no autoAdjustHeight
|
|
159
|
+
// re-render because enableMinHeightCheck is false)
|
|
160
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('still re-renders on resize even with enableAutoResize false (column widths need recalculation)', () => {
|
|
164
|
+
document.body.appendChild(element);
|
|
165
|
+
element.options = {
|
|
166
|
+
id: 'test-grid',
|
|
167
|
+
data: [{ name: 'Alice' }],
|
|
168
|
+
columnDefs: [{ name: 'name' }],
|
|
169
|
+
enableAutoResize: false,
|
|
170
|
+
enableMinHeightCheck: false,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const renderSpy = vi.spyOn(element as any, 'render');
|
|
174
|
+
renderSpy.mockClear();
|
|
175
|
+
|
|
176
|
+
const entries = [{ contentRect: { width: 500, height: 400 } }] as unknown as ResizeObserverEntry[];
|
|
177
|
+
resizeObserverCallback!(entries, element.autoResizeObserver as unknown as ResizeObserver);
|
|
178
|
+
|
|
179
|
+
flushRaf();
|
|
180
|
+
|
|
181
|
+
// Resize always triggers render for column width recalculation
|
|
182
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('cancels pending rAF on teardown', () => {
|
|
186
|
+
document.body.appendChild(element);
|
|
187
|
+
element.options = {
|
|
188
|
+
id: 'test-grid',
|
|
189
|
+
data: [{ name: 'Alice' }],
|
|
190
|
+
columnDefs: [{ name: 'name' }],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const renderSpy = vi.spyOn(element as any, 'render');
|
|
194
|
+
renderSpy.mockClear();
|
|
195
|
+
|
|
196
|
+
// Trigger resize
|
|
197
|
+
const entries = [{ contentRect: { width: 500, height: 400 } }] as unknown as ResizeObserverEntry[];
|
|
198
|
+
resizeObserverCallback!(entries, element.autoResizeObserver as unknown as ResizeObserver);
|
|
199
|
+
|
|
200
|
+
// Remove before rAF fires
|
|
201
|
+
element.remove();
|
|
202
|
+
|
|
203
|
+
// Flush — should not call render since the handle was cancelled
|
|
204
|
+
flushRaf();
|
|
205
|
+
expect(renderSpy).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } 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
|
+
/**
|
|
13
|
+
* cellNav public API parity tests. Validates the `gridApi.cellNav` surface
|
|
14
|
+
* ported from the old ui.grid.cellNav module — events (navigate,
|
|
15
|
+
* viewPortKeyDown, viewPortKeyPress) and methods (scrollToFocus,
|
|
16
|
+
* getFocusedCell, getCurrentSelection, rowColSelectIndex).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
22
|
+
const value = resolve();
|
|
23
|
+
if (value) return value;
|
|
24
|
+
await new Promise((r) => window.setTimeout(r, 10));
|
|
25
|
+
}
|
|
26
|
+
throw new Error('Timed out waiting for expected state');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TAG = 'ui-grid-element-vanilla-cellnav-test';
|
|
30
|
+
|
|
31
|
+
interface Row extends Record<string, unknown> {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
status: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
|
|
38
|
+
let capturedApi: UiGridApi | undefined;
|
|
39
|
+
const data: Row[] = [
|
|
40
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
41
|
+
{ id: 'r2', name: 'Beta', status: 'Pilot' },
|
|
42
|
+
{ id: 'r3', name: 'Gamma', status: 'Draft' },
|
|
43
|
+
];
|
|
44
|
+
const options: GridOptions = {
|
|
45
|
+
id: 'cellnav-grid',
|
|
46
|
+
data,
|
|
47
|
+
columnDefs: [
|
|
48
|
+
{ name: 'name', displayName: 'Name' },
|
|
49
|
+
{ name: 'status', displayName: 'Status' },
|
|
50
|
+
],
|
|
51
|
+
enableRowSelection: false,
|
|
52
|
+
rowIdentity: (entity) => String((entity as Row).id),
|
|
53
|
+
onRegisterApi: (api) => {
|
|
54
|
+
capturedApi = api as UiGridApi;
|
|
55
|
+
},
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
59
|
+
return options;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getApi(options: GridOptions): UiGridApi {
|
|
63
|
+
const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
64
|
+
if (!api) throw new Error('Grid API was never registered');
|
|
65
|
+
return api;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function mountGrid(options: GridOptions): Promise<{
|
|
69
|
+
grid: VanillaUiGridElement;
|
|
70
|
+
shadow: ShadowRoot;
|
|
71
|
+
}> {
|
|
72
|
+
const target = document.getElementById('app')!;
|
|
73
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
74
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
75
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"][data-column="name"]'));
|
|
76
|
+
return { grid, shadow };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cellIn(root: ShadowRoot, rowId: string, columnName: string): HTMLElement {
|
|
80
|
+
const selector = `.body-cell[data-row="${rowId}"][data-column="${columnName}"]`;
|
|
81
|
+
const el = root.querySelector<HTMLElement>(selector);
|
|
82
|
+
if (!el) throw new Error(`Cell ${rowId}/${columnName} not rendered`);
|
|
83
|
+
return el;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pressKey(el: HTMLElement, key: string, opts: { ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean } = {}): KeyboardEvent {
|
|
87
|
+
const event = new KeyboardEvent('keydown', {
|
|
88
|
+
key,
|
|
89
|
+
bubbles: true,
|
|
90
|
+
cancelable: true,
|
|
91
|
+
composed: true,
|
|
92
|
+
ctrlKey: !!opts.ctrlKey,
|
|
93
|
+
shiftKey: !!opts.shiftKey,
|
|
94
|
+
altKey: !!opts.altKey,
|
|
95
|
+
});
|
|
96
|
+
el.dispatchEvent(event);
|
|
97
|
+
return event;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('vanilla grid cellNav public API', () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
clearRustWasmGridEngine();
|
|
107
|
+
document.body.innerHTML = '';
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('getFocusedCell / navigate event', () => {
|
|
111
|
+
it('returns null before any cell has been focused', async () => {
|
|
112
|
+
const options = baseOptions();
|
|
113
|
+
await mountGrid(options);
|
|
114
|
+
const api = getApi(options);
|
|
115
|
+
expect(api.cellNav.getFocusedCell()).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('click records the focused cell and fires navigate', async () => {
|
|
119
|
+
const options = baseOptions();
|
|
120
|
+
const { shadow } = await mountGrid(options);
|
|
121
|
+
const api = getApi(options);
|
|
122
|
+
const listener = vi.fn();
|
|
123
|
+
api.cellNav.on.navigate(listener);
|
|
124
|
+
cellIn(shadow, 'r2', 'status').click();
|
|
125
|
+
const focused = api.cellNav.getFocusedCell();
|
|
126
|
+
expect(focused).not.toBeNull();
|
|
127
|
+
expect(focused!.row.id).toBe('r2');
|
|
128
|
+
expect(focused!.col.name).toBe('status');
|
|
129
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
130
|
+
// navigate(newRowCol, oldRowCol) — second arg null since this was the
|
|
131
|
+
// first focus.
|
|
132
|
+
const [newRowCol, oldRowCol] = listener.mock.calls[0]!;
|
|
133
|
+
expect(newRowCol.row.id).toBe('r2');
|
|
134
|
+
expect(newRowCol.col.name).toBe('status');
|
|
135
|
+
expect(oldRowCol).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('arrow navigation updates getFocusedCell and fires navigate', async () => {
|
|
139
|
+
const options = baseOptions();
|
|
140
|
+
const { shadow } = await mountGrid(options);
|
|
141
|
+
const api = getApi(options);
|
|
142
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
143
|
+
const listener = vi.fn();
|
|
144
|
+
api.cellNav.on.navigate(listener);
|
|
145
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
146
|
+
expect(api.cellNav.getFocusedCell()!.row.id).toBe('r2');
|
|
147
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
148
|
+
const [newRowCol, oldRowCol] = listener.mock.calls[0]!;
|
|
149
|
+
expect(newRowCol.row.id).toBe('r2');
|
|
150
|
+
expect(oldRowCol.row.id).toBe('r1');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('scrollToFocus', () => {
|
|
155
|
+
it('resolves and records focus on the target cell', async () => {
|
|
156
|
+
const options = baseOptions();
|
|
157
|
+
const { shadow } = await mountGrid(options);
|
|
158
|
+
const api = getApi(options);
|
|
159
|
+
await api.cellNav.scrollToFocus(
|
|
160
|
+
options.data[2]! as unknown as Record<string, unknown>,
|
|
161
|
+
options.columnDefs[1]!,
|
|
162
|
+
);
|
|
163
|
+
expect(api.cellNav.getFocusedCell()!.row.id).toBe('r3');
|
|
164
|
+
expect(api.cellNav.getFocusedCell()!.col.name).toBe('status');
|
|
165
|
+
// DOM focus follows.
|
|
166
|
+
await waitFor(() =>
|
|
167
|
+
shadow.activeElement === cellIn(shadow, 'r3', 'status') ? {} : null,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('scrollToFocus(null, null) clears focus', async () => {
|
|
172
|
+
const options = baseOptions();
|
|
173
|
+
const { shadow } = await mountGrid(options);
|
|
174
|
+
const api = getApi(options);
|
|
175
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
176
|
+
expect(api.cellNav.getFocusedCell()).not.toBeNull();
|
|
177
|
+
await api.cellNav.scrollToFocus(null, null);
|
|
178
|
+
expect(api.cellNav.getFocusedCell()).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('getCurrentSelection / rowColSelectIndex', () => {
|
|
183
|
+
it('tracks the single current focus cell by default', async () => {
|
|
184
|
+
const options = baseOptions();
|
|
185
|
+
const { shadow } = await mountGrid(options);
|
|
186
|
+
const api = getApi(options);
|
|
187
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
188
|
+
expect(api.cellNav.getCurrentSelection()).toHaveLength(1);
|
|
189
|
+
cellIn(shadow, 'r2', 'status').click();
|
|
190
|
+
// Default nav replaces focus — only one cell in the selection.
|
|
191
|
+
const selection = api.cellNav.getCurrentSelection();
|
|
192
|
+
expect(selection).toHaveLength(1);
|
|
193
|
+
expect(selection[0]!.row.id).toBe('r2');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('rowColSelectIndex returns -1 for cells not in the selection', async () => {
|
|
197
|
+
const options = baseOptions();
|
|
198
|
+
const { shadow } = await mountGrid(options);
|
|
199
|
+
const api = getApi(options);
|
|
200
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
201
|
+
const focused = api.cellNav.getFocusedCell()!;
|
|
202
|
+
expect(api.cellNav.rowColSelectIndex(focused)).toBe(0);
|
|
203
|
+
// A different cell is not in the selection.
|
|
204
|
+
const other = {
|
|
205
|
+
row: focused.row,
|
|
206
|
+
col: options.columnDefs[1]!,
|
|
207
|
+
};
|
|
208
|
+
expect(api.cellNav.rowColSelectIndex(other)).toBe(-1);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('viewPortKeyDown + keyDownOverrides', () => {
|
|
213
|
+
it('is NOT raised when no override matches', async () => {
|
|
214
|
+
const options = baseOptions();
|
|
215
|
+
const { shadow } = await mountGrid(options);
|
|
216
|
+
const api = getApi(options);
|
|
217
|
+
const listener = vi.fn();
|
|
218
|
+
api.cellNav.on.viewPortKeyDown(listener);
|
|
219
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
220
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
221
|
+
expect(listener).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('is raised when a keyDownOverride matches, skipping cellnav defaults', async () => {
|
|
225
|
+
const options = baseOptions({
|
|
226
|
+
keyDownOverrides: [{ key: 'ArrowDown' }],
|
|
227
|
+
});
|
|
228
|
+
const { shadow } = await mountGrid(options);
|
|
229
|
+
const api = getApi(options);
|
|
230
|
+
const listener = vi.fn();
|
|
231
|
+
api.cellNav.on.viewPortKeyDown(listener);
|
|
232
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
233
|
+
const before = api.cellNav.getFocusedCell()!;
|
|
234
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
235
|
+
// Override swallows the key — focus should NOT move to r2.
|
|
236
|
+
expect(api.cellNav.getFocusedCell()!.row.id).toBe(before.row.id);
|
|
237
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
238
|
+
const [event, rowCol] = listener.mock.calls[0]!;
|
|
239
|
+
expect(event).toBeInstanceOf(KeyboardEvent);
|
|
240
|
+
expect(rowCol.row.id).toBe('r1');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('matches keyDownOverride with modifier fields', async () => {
|
|
244
|
+
const options = baseOptions({
|
|
245
|
+
keyDownOverrides: [{ key: 'ArrowDown', shiftKey: true }],
|
|
246
|
+
});
|
|
247
|
+
const { shadow } = await mountGrid(options);
|
|
248
|
+
const api = getApi(options);
|
|
249
|
+
const listener = vi.fn();
|
|
250
|
+
api.cellNav.on.viewPortKeyDown(listener);
|
|
251
|
+
cellIn(shadow, 'r1', 'name').click();
|
|
252
|
+
// Plain ArrowDown: doesn't match the override, navigates normally.
|
|
253
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown');
|
|
254
|
+
expect(listener).not.toHaveBeenCalled();
|
|
255
|
+
// Shift+ArrowDown: matches the override, raises event, no nav.
|
|
256
|
+
const beforeId = api.cellNav.getFocusedCell()!.row.id;
|
|
257
|
+
pressKey(shadow.activeElement as HTMLElement, 'ArrowDown', { shiftKey: true });
|
|
258
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(api.cellNav.getFocusedCell()!.row.id).toBe(beforeId);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ui-grid-body-cell>` — Data cell element.
|
|
3
|
+
*
|
|
4
|
+
* An empty custom element. The grid shell pre-computes `class`, `style`,
|
|
5
|
+
* `tabindex`, and all `data-*` attributes directly into the emitted HTML
|
|
6
|
+
* string, so the browser parser finishes the element's visual state in one
|
|
7
|
+
* pass. No `attributeChangedCallback` work is required at upgrade time — at
|
|
8
|
+
* 1000+ cells per render, the callback storm was the single largest cost on
|
|
9
|
+
* the hot path.
|
|
10
|
+
*
|
|
11
|
+
* The class remains so the tag name resolves (`closest('ui-grid-body-cell')`
|
|
12
|
+
* still works) and so the element-specific CSS selectors target correctly.
|
|
13
|
+
* The parent is the single writer; cells are never mutated from outside.
|
|
14
|
+
*/
|
|
15
|
+
export class UIGridBodyCell extends HTMLElement {
|
|
16
|
+
static readonly TAG = 'ui-grid-body-cell';
|
|
17
|
+
|
|
18
|
+
static define(tagName = UIGridBodyCell.TAG): void {
|
|
19
|
+
if (!customElements.get(tagName)) {
|
|
20
|
+
customElements.define(tagName, UIGridBodyCell);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridBodyCell } from './grid-body-cell';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ui-grid-cell-editor>` — Single-input cell editor element.
|
|
3
|
+
*
|
|
4
|
+
* Light-DOM custom element. Mounts ONE `<input class="cell-editor">` in
|
|
5
|
+
* `connectedCallback` and reuses it across renders — every subsequent snapshot
|
|
6
|
+
* patches `value`/`type`/`disabled` in place instead of recreating the input.
|
|
7
|
+
* That keeps the keystroke path destructive-free: typing never rebuilds the
|
|
8
|
+
* focused node, so focus and caret survive the parent grid's re-renders.
|
|
9
|
+
*
|
|
10
|
+
* The inner `<input>` carries `data-role="editor"` + `data-row` + `data-column`
|
|
11
|
+
* so the parent grid's delegated `input` / `blur` handlers continue to find it
|
|
12
|
+
* via `composedPath()`/event.target unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Value updates coming in from the controller (`data-value`) are only pushed
|
|
15
|
+
* into the input when the input is NOT the currently focused element — this
|
|
16
|
+
* avoids clobbering the user's in-progress edit / caret position.
|
|
17
|
+
*/
|
|
18
|
+
export class UIGridCellEditor extends HTMLElement {
|
|
19
|
+
static readonly TAG = 'ui-grid-cell-editor';
|
|
20
|
+
|
|
21
|
+
private mounted = false;
|
|
22
|
+
private inputEl: HTMLInputElement | null = null;
|
|
23
|
+
|
|
24
|
+
static get observedAttributes(): string[] {
|
|
25
|
+
return ['data-row', 'data-column', 'data-type', 'data-value', 'data-disabled'];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
connectedCallback(): void {
|
|
29
|
+
if (!this.mounted) {
|
|
30
|
+
// Reuse a pre-existing <input> child if the element was parsed from
|
|
31
|
+
// innerHTML with one already inside (not expected, but defensive);
|
|
32
|
+
// otherwise create it fresh.
|
|
33
|
+
let input = this.querySelector<HTMLInputElement>('input.cell-editor');
|
|
34
|
+
if (!input) {
|
|
35
|
+
input = document.createElement('input');
|
|
36
|
+
input.className = 'cell-editor';
|
|
37
|
+
this.appendChild(input);
|
|
38
|
+
}
|
|
39
|
+
input.dataset['role'] = 'editor';
|
|
40
|
+
this.inputEl = input;
|
|
41
|
+
this.mounted = true;
|
|
42
|
+
}
|
|
43
|
+
this.sync(true);
|
|
44
|
+
// On first mount, place focus + select contents so the user can start
|
|
45
|
+
// typing immediately — matches the behaviour of a fresh <input> being
|
|
46
|
+
// inserted and clicked into.
|
|
47
|
+
const input = this.inputEl;
|
|
48
|
+
if (input && this.getAttribute('data-autofocus') !== 'false') {
|
|
49
|
+
try {
|
|
50
|
+
input.focus({ preventScroll: true });
|
|
51
|
+
} catch {
|
|
52
|
+
input.focus();
|
|
53
|
+
}
|
|
54
|
+
input.select();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
attributeChangedCallback(): void {
|
|
59
|
+
if (this.mounted) {
|
|
60
|
+
this.sync(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private sync(initial: boolean): void {
|
|
65
|
+
const input = this.inputEl;
|
|
66
|
+
if (!input) return;
|
|
67
|
+
|
|
68
|
+
const rowId = this.getAttribute('data-row') ?? '';
|
|
69
|
+
const columnName = this.getAttribute('data-column') ?? '';
|
|
70
|
+
const type = this.getAttribute('data-type') || 'text';
|
|
71
|
+
const value = this.getAttribute('data-value') ?? '';
|
|
72
|
+
const disabled = this.getAttribute('data-disabled') === 'true';
|
|
73
|
+
|
|
74
|
+
if (input.dataset['row'] !== rowId) {
|
|
75
|
+
input.dataset['row'] = rowId;
|
|
76
|
+
}
|
|
77
|
+
if (input.dataset['column'] !== columnName) {
|
|
78
|
+
input.dataset['column'] = columnName;
|
|
79
|
+
}
|
|
80
|
+
if (input.type !== type) {
|
|
81
|
+
input.type = type;
|
|
82
|
+
}
|
|
83
|
+
// Only push value in from the outside when:
|
|
84
|
+
// - initial mount (seed the input), OR
|
|
85
|
+
// - input is NOT currently focused (external programmatic update).
|
|
86
|
+
// Skipping this during typing preserves the caret.
|
|
87
|
+
const rootNode = input.getRootNode() as Document | ShadowRoot;
|
|
88
|
+
const isFocused = (rootNode as Document | ShadowRoot).activeElement === input;
|
|
89
|
+
if (initial || !isFocused) {
|
|
90
|
+
if (input.value !== value) {
|
|
91
|
+
input.value = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (input.disabled !== disabled) {
|
|
95
|
+
input.disabled = disabled;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static define(tagName = UIGridCellEditor.TAG): void {
|
|
100
|
+
if (!customElements.get(tagName)) {
|
|
101
|
+
customElements.define(tagName, UIGridCellEditor);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UIGridCellEditor } from './grid-cell-editor';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Filter cell shadow-scoped styles — composed entirely from core mixins.
|
|
2
|
+
@use "@ornery/ui-grid-core/src/lib/styles/filter-cell" as fc;
|
|
3
|
+
|
|
4
|
+
:host {
|
|
5
|
+
@include fc.host-shadow;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
input {
|
|
9
|
+
@include fc.input-shadow;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
input:focus {
|
|
13
|
+
@include fc.input-focus;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import css from './grid-filter-cell.scss';
|
|
2
|
+
import { adoptStyles } from '../../adopt-styles';
|
|
3
|
+
|
|
4
|
+
const STYLE_KEY = {};
|
|
5
|
+
|
|
6
|
+
interface FilterCellContext {
|
|
7
|
+
columnName: string;
|
|
8
|
+
placeholder: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function renderFilterCell(ctx: FilterCellContext, shadowRoot: ShadowRoot): void {
|
|
13
|
+
adoptStyles(shadowRoot, css, STYLE_KEY);
|
|
14
|
+
shadowRoot.innerHTML =
|
|
15
|
+
`<input class="ui-grid-filter-input"` +
|
|
16
|
+
` data-role="filter"` +
|
|
17
|
+
` data-column="${ctx.columnName}"` +
|
|
18
|
+
` placeholder="${ctx.placeholder}"` +
|
|
19
|
+
` value="${ctx.value}">`;
|
|
20
|
+
}
|