@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.
Files changed (63) hide show
  1. package/AGENTS.md +70 -0
  2. package/README.md +196 -0
  3. package/demo/main.ts +0 -1
  4. package/dist/index.d.mts +582 -55
  5. package/dist/index.d.ts +582 -55
  6. package/dist/index.js +4959 -1429
  7. package/dist/index.mjs +5137 -1560
  8. package/package.json +11 -8
  9. package/scss-plugin.ts +65 -0
  10. package/src/adopt-styles.ts +39 -0
  11. package/src/attribute-bridge.ts +291 -0
  12. package/src/auto-height.test.ts +142 -0
  13. package/src/auto-resize.test.ts +207 -0
  14. package/src/cell-nav.test.ts +262 -0
  15. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  16. package/src/components/grid-body-cell/index.ts +1 -0
  17. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  18. package/src/components/grid-cell-editor/index.ts +1 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  21. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  22. package/src/components/grid-filter-cell/index.ts +1 -0
  23. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  24. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  25. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  26. package/src/components/grid-group-row/index.ts +1 -0
  27. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  28. package/src/components/grid-header-cell/index.ts +1 -0
  29. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  30. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  31. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  32. package/src/components/grid-pagination/index.ts +1 -0
  33. package/src/components/grid-template/grid-template.ts +166 -0
  34. package/src/components/grid-template/index.ts +1 -0
  35. package/src/events.ts +824 -0
  36. package/src/exporter.test.ts +186 -0
  37. package/src/focus.ts +379 -0
  38. package/src/framework-slots.ts +440 -0
  39. package/src/grid-controller.ts +1532 -21
  40. package/src/icons.ts +88 -0
  41. package/src/importer.test.ts +147 -0
  42. package/src/index.integration.test.ts +2 -2
  43. package/src/index.ts +9 -0
  44. package/src/infinite-scroll.test.ts +220 -0
  45. package/src/keyboard-navigation.test.ts +579 -0
  46. package/src/patch.ts +722 -0
  47. package/src/render.ts +465 -0
  48. package/src/row-edit.test.ts +191 -0
  49. package/src/row-selection.test.ts +550 -0
  50. package/src/save-state.test.ts +235 -0
  51. package/src/scss.d.ts +4 -0
  52. package/src/templates/index.ts +62 -0
  53. package/src/test-setup.ts +23 -0
  54. package/src/ui-grid-empty.template.ts +23 -0
  55. package/src/ui-grid-shell.scss +25 -0
  56. package/src/ui-grid-shell.template.ts +45 -0
  57. package/src/ui-grid-standalone.element.ts +751 -1112
  58. package/src/utils/cell-class.ts +70 -0
  59. package/src/utils/display-items.ts +15 -0
  60. package/src/utils/dom.ts +64 -0
  61. package/src/validate.test.ts +170 -0
  62. package/tsup.config.ts +10 -0
  63. package/vite.config.ts +10 -1
package/src/icons.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Icon registry for the vanilla grid element.
3
+ *
4
+ * The grid draws its own sort / group / tree / expand / pin / pagination
5
+ * icons via inline SVGs. Consumers can override any individual icon by
6
+ * passing an `iconOverrides` map in GridOptions or by writing to the
7
+ * element's `controlIcons` property at runtime. This module owns the
8
+ * icon registry class plus the SVG markup helpers so the element file
9
+ * doesn't have to.
10
+ */
11
+
12
+ import { iconMarkup } from './templates';
13
+
14
+ export type UiGridControlIconKey =
15
+ | 'sortNone'
16
+ | 'sortAsc'
17
+ | 'sortDesc'
18
+ | 'group'
19
+ | 'groupExpanded'
20
+ | 'groupCollapsed'
21
+ | 'treeExpanded'
22
+ | 'treeCollapsed'
23
+ | 'expandExpanded'
24
+ | 'expandCollapsed'
25
+ | 'pin'
26
+ | 'pinLeft'
27
+ | 'pinRight'
28
+ | 'paginationPrev'
29
+ | 'paginationNext';
30
+
31
+ export interface UiGridIconDefinition {
32
+ viewBox?: string;
33
+ path: string;
34
+ }
35
+
36
+ export type UiGridIconOverrides = Partial<Record<UiGridControlIconKey, UiGridIconDefinition>>;
37
+
38
+ export const DEFAULT_ICONS: Record<UiGridControlIconKey, UiGridIconDefinition> = {
39
+ sortNone: { path: 'M7 6h10v2H7V6Zm0 5h7v2H7v-2Zm0 5h4v2H7v-2Z' },
40
+ sortAsc: { path: 'M12 5l-6 6h4v8h4v-8h4z' },
41
+ sortDesc: { path: 'M12 19l6-6h-4V5h-4v8H6z' },
42
+ group: { path: 'M4 6h8v4H4V6Zm0 8h8v4H4v-4Zm10-8h6v4h-6V6Zm0 8h6v4h-6v-4Z' },
43
+ groupExpanded: { path: 'M7 10l5 5 5-5z' },
44
+ groupCollapsed: { path: 'M10 7l5 5-5 5z' },
45
+ treeExpanded: { path: 'M7 10l5 5 5-5z' },
46
+ treeCollapsed: { path: 'M10 7l5 5-5 5z' },
47
+ expandExpanded: { path: 'M7 10l5 5 5-5z' },
48
+ expandCollapsed: { path: 'M10 7l5 5-5 5z' },
49
+ pin: { path: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5v6l1 1 1-1v-6h5v-2l-2-2z' },
50
+ pinLeft: { path: 'M10 6 4 12l6 6v-4h10v-4H10V6z' },
51
+ pinRight: { path: 'M14 6v4H4v4h10v4l6-6-6-6z' },
52
+ paginationPrev: { path: 'M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z' },
53
+ paginationNext: { path: 'M8.59 16.59 10 18l6-6-6-6-1.41 1.41L13.17 12z' },
54
+ };
55
+
56
+ /**
57
+ * Holds the active set of icons (defaults merged with consumer overrides)
58
+ * and renders the inline SVG markup for each one. A single instance lives
59
+ * on the element.
60
+ */
61
+ export class IconRegistry {
62
+ private overrides: UiGridIconOverrides = {};
63
+ private resolved: Record<UiGridControlIconKey, UiGridIconDefinition> = { ...DEFAULT_ICONS };
64
+
65
+ getOverrides(): UiGridIconOverrides {
66
+ return { ...this.overrides };
67
+ }
68
+
69
+ setOverrides(value: UiGridIconOverrides): void {
70
+ this.overrides = value ?? {};
71
+ this.resolved = { ...DEFAULT_ICONS, ...this.overrides };
72
+ }
73
+
74
+ resolve(key: UiGridControlIconKey): UiGridIconDefinition {
75
+ return this.resolved[key];
76
+ }
77
+
78
+ /** Render the SVG markup for a control icon (sort, group, pin, …). */
79
+ renderControlIcon(key: UiGridControlIconKey): string {
80
+ return this.renderIconWithClass('control-icon', key);
81
+ }
82
+
83
+ /** Render the SVG markup with an arbitrary wrapper class. */
84
+ renderIconWithClass(svgClass: string, key: UiGridControlIconKey): string {
85
+ const icon = this.resolve(key);
86
+ return iconMarkup(svgClass, icon.viewBox ?? '0 0 24 24', icon.path);
87
+ }
88
+ }
@@ -0,0 +1,147 @@
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
+ * Integration tests for the importer. Ports the behaviour from
14
+ * ui.grid.importer onto the vanilla grid: the element owns the file
15
+ * picker + FileReader; the controller owns the pure parse / dispatch
16
+ * pipeline. Consumers wire `importerDataAddCallback` to receive the
17
+ * parsed rows.
18
+ */
19
+
20
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
21
+ const startedAt = Date.now();
22
+ while (Date.now() - startedAt < timeoutMs) {
23
+ const value = resolve();
24
+ if (value) return value;
25
+ await new Promise((r) => window.setTimeout(r, 10));
26
+ }
27
+ throw new Error('Timed out waiting for expected state');
28
+ }
29
+
30
+ const TAG = 'ui-grid-element-vanilla-importer-test';
31
+
32
+ interface Row extends Record<string, unknown> {
33
+ id: string;
34
+ name: string;
35
+ status: string;
36
+ }
37
+
38
+ function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
39
+ let capturedApi: UiGridApi | undefined;
40
+ const data: Row[] = [];
41
+ const options: GridOptions = {
42
+ id: 'importer-grid',
43
+ data,
44
+ columnDefs: [
45
+ { name: 'id', field: 'id' },
46
+ { name: 'name', displayName: 'Full Name' },
47
+ { name: 'status' },
48
+ ],
49
+ enableImporter: true,
50
+ rowIdentity: (entity) => String((entity as Row).id),
51
+ onRegisterApi: (api) => {
52
+ capturedApi = api as UiGridApi;
53
+ },
54
+ ...overrides,
55
+ };
56
+ Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
57
+ return options;
58
+ }
59
+
60
+ function getApi(options: GridOptions): UiGridApi {
61
+ const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
62
+ if (!api) throw new Error('Grid API was never registered');
63
+ return api;
64
+ }
65
+
66
+ async function mountGrid(options: GridOptions): Promise<{
67
+ grid: VanillaUiGridElement;
68
+ shadow: ShadowRoot;
69
+ }> {
70
+ const target = document.getElementById('app')!;
71
+ const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
72
+ const shadow = await waitFor(() => grid.shadowRoot);
73
+ return { grid, shadow };
74
+ }
75
+
76
+ describe('vanilla grid importer', () => {
77
+ beforeEach(() => {
78
+ document.body.innerHTML = '<div id="app"></div>';
79
+ });
80
+
81
+ afterEach(() => {
82
+ clearRustWasmGridEngine();
83
+ document.body.innerHTML = '';
84
+ });
85
+
86
+ it('parses a CSV string + invokes importerDataAddCallback', async () => {
87
+ const importerDataAddCallback = vi.fn();
88
+ const options = baseOptions({ importerDataAddCallback });
89
+ await mountGrid(options);
90
+ const api = getApi(options);
91
+ api.importer.importText('id,Full Name,status\n1,Alpha,Active\n2,Beta,Pilot', 'csv');
92
+ expect(importerDataAddCallback).toHaveBeenCalledTimes(1);
93
+ const [newObjects] = importerDataAddCallback.mock.calls[0]!;
94
+ expect(newObjects).toEqual([
95
+ { id: '1', name: 'Alpha', status: 'Active' },
96
+ { id: '2', name: 'Beta', status: 'Pilot' },
97
+ ]);
98
+ });
99
+
100
+ it('parses a JSON string when type is "json"', async () => {
101
+ const importerDataAddCallback = vi.fn();
102
+ const options = baseOptions({ importerDataAddCallback });
103
+ await mountGrid(options);
104
+ const api = getApi(options);
105
+ api.importer.importText('[{"id":"1","name":"Alpha"}]', 'json');
106
+ expect(importerDataAddCallback).toHaveBeenCalledTimes(1);
107
+ const [objects] = importerDataAddCallback.mock.calls[0]!;
108
+ expect(objects).toEqual([{ id: '1', name: 'Alpha' }]);
109
+ });
110
+
111
+ it('auto-detects JSON vs CSV when no type is passed', async () => {
112
+ const importerDataAddCallback = vi.fn();
113
+ const options = baseOptions({ importerDataAddCallback });
114
+ await mountGrid(options);
115
+ const api = getApi(options);
116
+ api.importer.importText('[{"id":"j"}]');
117
+ api.importer.importText('id\nc');
118
+ expect(importerDataAddCallback).toHaveBeenCalledTimes(2);
119
+ expect(importerDataAddCallback.mock.calls[0]![0]).toEqual([{ id: 'j' }]);
120
+ expect(importerDataAddCallback.mock.calls[1]![0]).toEqual([{ id: 'c' }]);
121
+ });
122
+
123
+ it('raises the errorCallback when CSV headers produce no objects', async () => {
124
+ const importerDataAddCallback = vi.fn();
125
+ const importerErrorCallback = vi.fn();
126
+ const options = baseOptions({ importerDataAddCallback, importerErrorCallback });
127
+ await mountGrid(options);
128
+ const api = getApi(options);
129
+ // Header-only CSV — no data rows, so objects will be empty.
130
+ api.importer.importText('id,name', 'csv');
131
+ expect(importerDataAddCallback).not.toHaveBeenCalled();
132
+ expect(importerErrorCallback).toHaveBeenCalledWith(
133
+ 'importer.noObjects',
134
+ expect.any(String),
135
+ 'id,name',
136
+ );
137
+ });
138
+
139
+ it('appends to options.data when no importerDataAddCallback is set', async () => {
140
+ const options = baseOptions(); // no callback
141
+ await mountGrid(options);
142
+ const api = getApi(options);
143
+ api.importer.importText('id,name\n1,Alpha', 'csv');
144
+ expect(api.core.getVisibleRows().length).toBe(1);
145
+ expect(api.core.getVisibleRows()[0]!.entity).toMatchObject({ id: '1', name: 'Alpha' });
146
+ });
147
+ });
@@ -298,11 +298,11 @@ describe('mountVanillaUiGrid integration', () => {
298
298
  // Simulate the bridge pattern: augment without losing attribute-derived data.
299
299
  grid.options = {
300
300
  ...grid.options,
301
- viewportHeight: 999,
301
+ minRowsToShow: 20,
302
302
  };
303
303
 
304
304
  expect((grid.options.data as Array<{ id: string; name: string }>)[0].name).toBe('Gamma');
305
- expect(grid.options.viewportHeight).toBe(999);
305
+ expect(grid.options.minRowsToShow).toBe(20);
306
306
  });
307
307
 
308
308
  });
package/src/index.ts CHANGED
@@ -18,8 +18,17 @@ export {
18
18
  type UiGridControlIconKey,
19
19
  type UiGridIconDefinition,
20
20
  type UiGridIconOverrides,
21
+ type FrameworkRenderedSlotsConfig,
22
+ type FrameworkCellSlot,
23
+ type FrameworkHeaderSlot,
24
+ type FrameworkFilterSlot,
25
+ type FrameworkGroupRowSlot,
26
+ type FrameworkExpandableRowSlot,
27
+ type FrameworkEmptyStateSlot,
28
+ type FrameworkSlotDelta,
21
29
  } from './ui-grid-standalone.element';
22
30
 
31
+ export { UIGridTemplate } from './components/grid-template';
23
32
  export type { GridOptions, UiGridApi } from '@ornery/ui-grid-core';
24
33
 
25
34
  export interface UiGridRustWebModule {
@@ -0,0 +1,220 @@
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
+ * Infinite-scroll integration tests. Ports the behaviours from the old
14
+ * ui.grid.infiniteScroll module and verifies the vanilla element's
15
+ * scroll-handler wiring raises the right events and the public API
16
+ * methods (dataLoaded/resetScroll/saveScrollPercentage/dataRemoved* /
17
+ * setScrollDirections) flow through the controller state helpers.
18
+ */
19
+
20
+ async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
21
+ const startedAt = Date.now();
22
+ while (Date.now() - startedAt < timeoutMs) {
23
+ const value = resolve();
24
+ if (value) return value;
25
+ await new Promise((r) => window.setTimeout(r, 10));
26
+ }
27
+ throw new Error('Timed out waiting for expected state');
28
+ }
29
+
30
+ const TAG = 'ui-grid-element-vanilla-infinite-test';
31
+
32
+ interface Row extends Record<string, unknown> {
33
+ id: string;
34
+ name: string;
35
+ }
36
+
37
+ function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
38
+ let capturedApi: UiGridApi | undefined;
39
+ const data: Row[] = Array.from({ length: 200 }, (_, i) => ({
40
+ id: `r${i}`,
41
+ name: `Row ${i}`,
42
+ }));
43
+ const options: GridOptions = {
44
+ id: 'infinite-scroll-grid',
45
+ data,
46
+ columnDefs: [{ name: 'name', displayName: 'Name' }],
47
+ enableRowSelection: false,
48
+ rowIdentity: (entity) => String((entity as Row).id),
49
+ rowHeight: 20,
50
+ virtualizationThreshold: 1,
51
+ onRegisterApi: (api) => {
52
+ capturedApi = api as UiGridApi;
53
+ },
54
+ ...overrides,
55
+ };
56
+ Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
57
+ return options;
58
+ }
59
+
60
+ function getApi(options: GridOptions): UiGridApi {
61
+ const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
62
+ if (!api) throw new Error('Grid API was never registered');
63
+ return api;
64
+ }
65
+
66
+ async function mountGrid(options: GridOptions): Promise<{
67
+ grid: VanillaUiGridElement;
68
+ shadow: ShadowRoot;
69
+ }> {
70
+ const target = document.getElementById('app')!;
71
+ const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
72
+ const shadow = await waitFor(() => grid.shadowRoot);
73
+ await waitFor(() => shadow.querySelector('.grid-table'));
74
+ return { grid, shadow };
75
+ }
76
+
77
+ function scrollGrid(shadow: ShadowRoot, top: number): void {
78
+ const viewport = shadow.querySelector<HTMLElement>('.grid-body-viewport');
79
+ if (!viewport) throw new Error('grid-body-viewport not rendered');
80
+ viewport.scrollTop = top;
81
+ // Patch clientHeight in jsdom so the maybeTriggerInfiniteScroll math
82
+ // sees a viewport — jsdom otherwise returns 0 for clientHeight.
83
+ Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 });
84
+ viewport.dispatchEvent(new Event('scroll', { bubbles: true }));
85
+ }
86
+
87
+ async function waitFrame(): Promise<void> {
88
+ // The scroll handler schedules work in rAF. Two rAFs + a micro-yield is
89
+ // enough to flush the chain in jsdom.
90
+ await new Promise((r) => requestAnimationFrame(() => r(undefined)));
91
+ await new Promise((r) => requestAnimationFrame(() => r(undefined)));
92
+ await new Promise((r) => window.setTimeout(r, 0));
93
+ }
94
+
95
+ describe('vanilla grid infinite scroll', () => {
96
+ beforeEach(() => {
97
+ document.body.innerHTML = '<div id="app"></div>';
98
+ });
99
+
100
+ afterEach(() => {
101
+ clearRustWasmGridEngine();
102
+ document.body.innerHTML = '';
103
+ });
104
+
105
+ describe('events', () => {
106
+ it('raises needLoadMoreData when the user scrolls near the bottom', async () => {
107
+ const options = baseOptions({ infiniteScrollDown: true });
108
+ const { shadow } = await mountGrid(options);
109
+ const api = getApi(options);
110
+ const listener = vi.fn();
111
+ api.infiniteScroll.on.needLoadMoreData(listener);
112
+ // Scroll near the end — viewport=200/row=20 → 10 rows visible,
113
+ // threshold=20 (default), total=200 rows → trigger at startIndex>=170.
114
+ scrollGrid(shadow, 200 * 20);
115
+ await waitFrame();
116
+ expect(listener).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('raises needLoadMoreDataTop when the user scrolls near the top (scrollUp=true)', async () => {
120
+ const options = baseOptions({ infiniteScrollUp: true });
121
+ const { shadow } = await mountGrid(options);
122
+ const api = getApi(options);
123
+ // Start mid-grid.
124
+ scrollGrid(shadow, 50 * 20);
125
+ await waitFrame();
126
+ const listener = vi.fn();
127
+ api.infiniteScroll.on.needLoadMoreDataTop(listener);
128
+ // Now scroll back near the top — startIndex ≤ threshold (20) fires top.
129
+ scrollGrid(shadow, 0);
130
+ await waitFrame();
131
+ expect(listener).toHaveBeenCalledTimes(1);
132
+ });
133
+
134
+ it('does not raise anything when enableInfiniteScroll is false', async () => {
135
+ const options = baseOptions({
136
+ enableInfiniteScroll: false,
137
+ infiniteScrollDown: true,
138
+ });
139
+ const { shadow } = await mountGrid(options);
140
+ const api = getApi(options);
141
+ const listener = vi.fn();
142
+ api.infiniteScroll.on.needLoadMoreData(listener);
143
+ scrollGrid(shadow, 200 * 20);
144
+ await waitFrame();
145
+ expect(listener).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('suppresses the event when direction flag is off (default scrollUp=false)', async () => {
149
+ const options = baseOptions(); // scrollUp defaults to false
150
+ const { shadow } = await mountGrid(options);
151
+ const api = getApi(options);
152
+ const listener = vi.fn();
153
+ api.infiniteScroll.on.needLoadMoreDataTop(listener);
154
+ scrollGrid(shadow, 0);
155
+ await waitFrame();
156
+ expect(listener).not.toHaveBeenCalled();
157
+ });
158
+ });
159
+
160
+ describe('public API methods', () => {
161
+ it('dataLoaded clears the loading flag so subsequent scrolls can trigger again', async () => {
162
+ const options = baseOptions({ infiniteScrollDown: true });
163
+ const { shadow } = await mountGrid(options);
164
+ const api = getApi(options);
165
+ const listener = vi.fn();
166
+ api.infiniteScroll.on.needLoadMoreData(listener);
167
+ scrollGrid(shadow, 200 * 20);
168
+ await waitFrame();
169
+ expect(listener).toHaveBeenCalledTimes(1);
170
+ // Loading flag is now true; a second near-bottom scroll must NOT
171
+ // retrigger until dataLoaded resets it.
172
+ scrollGrid(shadow, 200 * 20 - 40);
173
+ scrollGrid(shadow, 200 * 20);
174
+ await waitFrame();
175
+ expect(listener).toHaveBeenCalledTimes(1);
176
+ // Reset loading flag — now the next near-bottom scroll fires again.
177
+ await api.infiniteScroll.dataLoaded(true, true);
178
+ await waitFrame();
179
+ scrollGrid(shadow, 200 * 20 - 40);
180
+ await waitFrame();
181
+ scrollGrid(shadow, 200 * 20);
182
+ await waitFrame();
183
+ expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
184
+ });
185
+
186
+ it('setScrollDirections toggles which events fire', async () => {
187
+ const options = baseOptions({ infiniteScrollUp: true, infiniteScrollDown: true });
188
+ const { shadow } = await mountGrid(options);
189
+ const api = getApi(options);
190
+ const topListener = vi.fn();
191
+ api.infiniteScroll.on.needLoadMoreDataTop(topListener);
192
+ api.infiniteScroll.setScrollDirections(false, true);
193
+ scrollGrid(shadow, 0);
194
+ await waitFrame();
195
+ expect(topListener).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('saveScrollPercentage + dataRemovedTop update state without crashing', async () => {
199
+ const options = baseOptions();
200
+ await mountGrid(options);
201
+ const api = getApi(options);
202
+ // Should be no-op in jsdom but exercises the code paths.
203
+ api.infiniteScroll.saveScrollPercentage();
204
+ api.infiniteScroll.dataRemovedTop(true, true);
205
+ api.infiniteScroll.dataRemovedBottom(true, true);
206
+ expect(api.infiniteScroll).toBeDefined();
207
+ });
208
+
209
+ it('resetScroll returns the viewport to the top', async () => {
210
+ const options = baseOptions({ infiniteScrollUp: true });
211
+ const { shadow } = await mountGrid(options);
212
+ const api = getApi(options);
213
+ scrollGrid(shadow, 50 * 20);
214
+ const viewport = shadow.querySelector<HTMLElement>('.grid-body-viewport')!;
215
+ expect(viewport.scrollTop).toBeGreaterThan(0);
216
+ api.infiniteScroll.resetScroll(true, true);
217
+ expect(viewport.scrollTop).toBe(0);
218
+ });
219
+ });
220
+ });