@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class-name builders for header / body cells. Shared between the initial
|
|
3
|
+
* markup builder and the patch path so both code paths produce identical
|
|
4
|
+
* output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function bodyCellClass(
|
|
8
|
+
isOdd: boolean,
|
|
9
|
+
align: string,
|
|
10
|
+
isPinned: boolean,
|
|
11
|
+
isPinnedLeftLast: boolean,
|
|
12
|
+
isPinnedRightFirst: boolean,
|
|
13
|
+
isFocused: boolean,
|
|
14
|
+
isEditing: boolean,
|
|
15
|
+
isRowSelected: boolean,
|
|
16
|
+
isRowFocused: boolean,
|
|
17
|
+
isRowDirty: boolean,
|
|
18
|
+
isRowSaving: boolean,
|
|
19
|
+
isRowError: boolean,
|
|
20
|
+
isCellInvalid: boolean,
|
|
21
|
+
): string {
|
|
22
|
+
let cls = 'body-cell ui-grid-cell';
|
|
23
|
+
if (isOdd) cls += ' body-cell-odd';
|
|
24
|
+
if (align === 'center') cls += ' align-center';
|
|
25
|
+
else if (align === 'end') cls += ' align-end';
|
|
26
|
+
if (isPinned) cls += ' is-pinned';
|
|
27
|
+
if (isPinnedLeftLast) cls += ' is-pinned-left-last';
|
|
28
|
+
if (isPinnedRightFirst) cls += ' is-pinned-right-first';
|
|
29
|
+
if (isFocused) cls += ' cell-focused';
|
|
30
|
+
if (isEditing) cls += ' cell-editing';
|
|
31
|
+
// Matches the old ui.grid.selection directive's ng-class output. Row-level
|
|
32
|
+
// state shows on every cell so selection stripes work across the whole row.
|
|
33
|
+
if (isRowSelected) cls += ' ui-grid-row-selected';
|
|
34
|
+
if (isRowFocused) cls += ' ui-grid-row-focused';
|
|
35
|
+
// Row-edit — matches the old `ui.grid.rowEdit` uiGridViewport directive's
|
|
36
|
+
// ng-class output. Repeated per-cell so the full-row stripe works.
|
|
37
|
+
if (isRowDirty) cls += ' ui-grid-row-dirty';
|
|
38
|
+
if (isRowSaving) cls += ' ui-grid-row-saving';
|
|
39
|
+
if (isRowError) cls += ' ui-grid-row-error';
|
|
40
|
+
// Validate — paints the invalid-cell badge + background when a validator
|
|
41
|
+
// has flagged this cell. Ports the `.invalid` class from ui.grid.validate.
|
|
42
|
+
if (isCellInvalid) cls += ' ui-grid-cell-invalid';
|
|
43
|
+
return cls;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function headerCellClass(
|
|
47
|
+
isSortActive: boolean,
|
|
48
|
+
isPinned: boolean,
|
|
49
|
+
isPinnedLeftLast: boolean,
|
|
50
|
+
isPinnedRightFirst: boolean,
|
|
51
|
+
isPinMenuOpen: boolean,
|
|
52
|
+
isDragTarget: boolean,
|
|
53
|
+
isDragging: boolean,
|
|
54
|
+
): string {
|
|
55
|
+
let cls = 'header-cell';
|
|
56
|
+
if (isSortActive) cls += ' is-active';
|
|
57
|
+
if (isPinned) cls += ' is-pinned';
|
|
58
|
+
if (isPinnedLeftLast) cls += ' is-pinned-left-last';
|
|
59
|
+
if (isPinnedRightFirst) cls += ' is-pinned-right-first';
|
|
60
|
+
if (isPinMenuOpen) cls += ' is-pin-menu-open';
|
|
61
|
+
if (isDragTarget) cls += ' is-drag-target';
|
|
62
|
+
if (isDragging) cls += ' is-dragging';
|
|
63
|
+
return cls;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cssEscape(value: string): string {
|
|
67
|
+
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
|
68
|
+
? CSS.escape(value)
|
|
69
|
+
: value.replace(/([\\".#:[\](){}+~> ])/g, '\\$1');
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DisplayItem, GridRow, GroupItem } from '@ornery/ui-grid-core';
|
|
2
|
+
|
|
3
|
+
/** Narrow a DisplayItem to a GroupItem. The grid pipeline's DisplayItem
|
|
4
|
+
* union already guarantees the `kind === 'group'` case; this helper keeps
|
|
5
|
+
* the cast in one place. */
|
|
6
|
+
export function asGroupItem(item: DisplayItem): GroupItem {
|
|
7
|
+
return item as GroupItem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Type guard for row items with the `row: GridRow` payload attached. */
|
|
11
|
+
export function isRowItem(
|
|
12
|
+
item: DisplayItem,
|
|
13
|
+
): item is DisplayItem & { kind: 'row'; row: GridRow } {
|
|
14
|
+
return item.kind === 'row';
|
|
15
|
+
}
|
package/src/utils/dom.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM + HTML utility helpers shared across the element's render / patch paths.
|
|
3
|
+
*
|
|
4
|
+
* `utils/*` modules may only depend on package-level imports — never on any
|
|
5
|
+
* other file in `src/`. Anything else risks forming an import cycle through
|
|
6
|
+
* the element root.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function escapeHtml(value: unknown): string {
|
|
10
|
+
const text = String(value ?? '');
|
|
11
|
+
return text
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setAttr(el: HTMLElement, name: string, value: string): void {
|
|
20
|
+
if (el.getAttribute(name) !== value) {
|
|
21
|
+
el.setAttribute(name, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setClass(el: HTMLElement, value: string): void {
|
|
26
|
+
if (el.className !== value) {
|
|
27
|
+
el.className = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setStyle(el: HTMLElement, value: string): void {
|
|
32
|
+
const current = el.getAttribute('style');
|
|
33
|
+
if (value) {
|
|
34
|
+
if (current !== value) el.setAttribute('style', value);
|
|
35
|
+
} else if (current !== null) {
|
|
36
|
+
el.removeAttribute('style');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a fragment of HTML and return its first element child. Returns null
|
|
42
|
+
* when the markup is empty or contains only non-element nodes.
|
|
43
|
+
*/
|
|
44
|
+
export function createFromMarkup(html: string): HTMLElement | null {
|
|
45
|
+
const wrapper = document.createElement('div');
|
|
46
|
+
wrapper.innerHTML = html;
|
|
47
|
+
return wrapper.firstElementChild as HTMLElement | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Swap an element inside a container, preserving the container's other
|
|
52
|
+
* children. Appends when there is no existing element.
|
|
53
|
+
*/
|
|
54
|
+
export function swapBodyChild(
|
|
55
|
+
container: HTMLElement,
|
|
56
|
+
current: HTMLElement | null,
|
|
57
|
+
next: HTMLElement,
|
|
58
|
+
): void {
|
|
59
|
+
if (current) {
|
|
60
|
+
container.replaceChild(next, current);
|
|
61
|
+
} else {
|
|
62
|
+
container.appendChild(next);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
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-validate-test';
|
|
23
|
+
|
|
24
|
+
interface Row extends Record<string, unknown> {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
|
|
30
|
+
let capturedApi: UiGridApi | undefined;
|
|
31
|
+
const data: Row[] = [
|
|
32
|
+
{ id: 'r1', name: 'Alpha' },
|
|
33
|
+
{ id: 'r2', name: '' },
|
|
34
|
+
{ id: 'r3', name: 'Gamma' },
|
|
35
|
+
];
|
|
36
|
+
const options: GridOptions = {
|
|
37
|
+
id: 'validate-grid',
|
|
38
|
+
data,
|
|
39
|
+
columnDefs: [
|
|
40
|
+
{ name: 'id' },
|
|
41
|
+
{
|
|
42
|
+
name: 'name',
|
|
43
|
+
displayName: 'Name',
|
|
44
|
+
enableCellEdit: true,
|
|
45
|
+
validators: { required: true, minLength: 3 },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
enableCellEdit: true,
|
|
49
|
+
rowIdentity: (entity) => String((entity as Row).id),
|
|
50
|
+
onRegisterApi: (api) => {
|
|
51
|
+
capturedApi = api as UiGridApi;
|
|
52
|
+
},
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
56
|
+
return options;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getApi(options: GridOptions): UiGridApi {
|
|
60
|
+
const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
61
|
+
if (!api) throw new Error('Grid API was never registered');
|
|
62
|
+
return api;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function mountGrid(options: GridOptions): Promise<{
|
|
66
|
+
grid: VanillaUiGridElement;
|
|
67
|
+
shadow: ShadowRoot;
|
|
68
|
+
}> {
|
|
69
|
+
const target = document.getElementById('app')!;
|
|
70
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
71
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
72
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"]'));
|
|
73
|
+
return { grid, shadow };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('vanilla grid validate', () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
clearRustWasmGridEngine();
|
|
83
|
+
document.body.innerHTML = '';
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('runValidators flips the cell invalid + raises validationFailed', async () => {
|
|
87
|
+
const options = baseOptions();
|
|
88
|
+
await mountGrid(options);
|
|
89
|
+
const api = getApi(options);
|
|
90
|
+
const handler = vi.fn();
|
|
91
|
+
api.validate.on.validationFailed(handler);
|
|
92
|
+
|
|
93
|
+
const col = options.columnDefs.find((c) => c.name === 'name')!;
|
|
94
|
+
await api.validate.runValidators(options.data[0] as Record<string, unknown>, col, '', 'Alpha');
|
|
95
|
+
|
|
96
|
+
expect(api.validate.isInvalid(options.data[0] as Record<string, unknown>, col)).toBe(true);
|
|
97
|
+
expect(handler).toHaveBeenCalled();
|
|
98
|
+
const errors = api.validate.getErrorMessages(
|
|
99
|
+
options.data[0] as Record<string, unknown>,
|
|
100
|
+
col,
|
|
101
|
+
);
|
|
102
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('getFormattedErrors returns an HTML block with the error label prefix', async () => {
|
|
106
|
+
const options = baseOptions();
|
|
107
|
+
await mountGrid(options);
|
|
108
|
+
const api = getApi(options);
|
|
109
|
+
const col = options.columnDefs.find((c) => c.name === 'name')!;
|
|
110
|
+
await api.validate.runValidators(options.data[0] as Record<string, unknown>, col, '', 'Alpha');
|
|
111
|
+
const html = api.validate.getFormattedErrors(options.data[0] as Record<string, unknown>, col);
|
|
112
|
+
expect(html).toContain('<p><b>');
|
|
113
|
+
expect(html).toContain('<br/>');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('getInvalidRows sweeps the full data and returns every offending entity', async () => {
|
|
117
|
+
const options = baseOptions();
|
|
118
|
+
await mountGrid(options);
|
|
119
|
+
const api = getApi(options);
|
|
120
|
+
const invalid = await api.validate.getInvalidRows();
|
|
121
|
+
// Row r2 has name="" which fails both `required` and `minLength`.
|
|
122
|
+
expect(invalid.length).toBe(1);
|
|
123
|
+
expect(invalid[0]!).toBe(options.data[1]!);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders the invalid class on cells whose value currently fails', async () => {
|
|
127
|
+
const options = baseOptions();
|
|
128
|
+
const { shadow } = await mountGrid(options);
|
|
129
|
+
const api = getApi(options);
|
|
130
|
+
const col = options.columnDefs.find((c) => c.name === 'name')!;
|
|
131
|
+
// Programmatically flip row 1 invalid via runValidators; the rendered
|
|
132
|
+
// cell should gain `.ui-grid-cell-invalid` on the next refresh.
|
|
133
|
+
await api.validate.runValidators(options.data[0] as Record<string, unknown>, col, '', 'Alpha');
|
|
134
|
+
api.core.refresh();
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
shadow.querySelector('.body-cell[data-row="r1"][data-column="name"].ui-grid-cell-invalid'),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('registers a custom validator via setValidator', async () => {
|
|
141
|
+
const options = baseOptions({
|
|
142
|
+
columnDefs: [
|
|
143
|
+
{ name: 'id' },
|
|
144
|
+
{
|
|
145
|
+
name: 'name',
|
|
146
|
+
enableCellEdit: true,
|
|
147
|
+
validators: { noSpaces: true },
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
await mountGrid(options);
|
|
152
|
+
const api = getApi(options);
|
|
153
|
+
api.validate.setValidator(
|
|
154
|
+
'noSpaces',
|
|
155
|
+
() => (_oldValue, newValue) => !/\s/.test(String(newValue ?? '')),
|
|
156
|
+
() => 'Spaces not allowed',
|
|
157
|
+
);
|
|
158
|
+
const col = options.columnDefs.find((c) => c.name === 'name')!;
|
|
159
|
+
await api.validate.runValidators(
|
|
160
|
+
options.data[0] as Record<string, unknown>,
|
|
161
|
+
col,
|
|
162
|
+
'has space',
|
|
163
|
+
'Alpha',
|
|
164
|
+
);
|
|
165
|
+
expect(api.validate.isInvalid(options.data[0] as Record<string, unknown>, col)).toBe(true);
|
|
166
|
+
expect(
|
|
167
|
+
api.validate.getErrorMessages(options.data[0] as Record<string, unknown>, col),
|
|
168
|
+
).toEqual(['Spaces not allowed']);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
import { scssInlineEsbuild } from './scss-plugin';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
entry: ['src/index.ts'],
|
|
6
|
+
format: ['esm', 'cjs'],
|
|
7
|
+
dts: true,
|
|
8
|
+
tsconfig: './tsconfig.json',
|
|
9
|
+
esbuildPlugins: [scssInlineEsbuild()],
|
|
10
|
+
});
|
package/vite.config.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { defineConfig } from 'vite';
|
|
3
|
+
import { scssInlinePlugin } from './scss-plugin';
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
6
|
root: path.resolve(__dirname, 'demo'),
|
|
7
|
+
plugins: [scssInlinePlugin()],
|
|
6
8
|
resolve: {
|
|
7
9
|
alias: {
|
|
8
10
|
'@ornery/ui-grid-core': path.resolve(__dirname, '../ui-grid-core/src/index.ts'),
|
|
9
11
|
},
|
|
10
12
|
},
|
|
13
|
+
css: {
|
|
14
|
+
preprocessorOptions: {
|
|
15
|
+
scss: {
|
|
16
|
+
loadPaths: [path.resolve(__dirname, 'node_modules')],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
11
20
|
optimizeDeps: {
|
|
12
21
|
exclude: ['@ornery/ui-grid-core'],
|
|
13
22
|
},
|
|
@@ -18,6 +27,6 @@ export default defineConfig({
|
|
|
18
27
|
},
|
|
19
28
|
test: {
|
|
20
29
|
dir: path.resolve(__dirname, 'src'),
|
|
21
|
-
environment: '
|
|
30
|
+
environment: 'happy-dom',
|
|
22
31
|
},
|
|
23
32
|
});
|