@rickcedwhat/playwright-smart-table 6.20.1 → 6.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/engine/rowFinder.d.ts +1 -0
  2. package/dist/engine/rowFinder.js +75 -75
  3. package/dist/engine/scanPages.d.ts +58 -0
  4. package/dist/engine/scanPages.js +46 -0
  5. package/dist/engine/tableIteration.d.ts +1 -0
  6. package/dist/engine/tableIteration.js +38 -20
  7. package/dist/filterEngine.d.ts +4 -0
  8. package/dist/filterEngine.js +15 -4
  9. package/dist/index.d.ts +6 -2
  10. package/dist/index.js +5 -1
  11. package/dist/packageVersion.d.ts +1 -1
  12. package/dist/packageVersion.js +1 -1
  13. package/dist/plugins/index.d.ts +51 -10
  14. package/dist/plugins/index.js +14 -6
  15. package/dist/presets/glide/index.d.ts +1 -1
  16. package/dist/presets/glide/index.js +2 -2
  17. package/dist/presets/rdg.d.ts +1 -1
  18. package/dist/presets/rdg.js +4 -4
  19. package/dist/smartRow.d.ts +2 -2
  20. package/dist/smartRow.js +160 -49
  21. package/dist/strategies/columns.d.ts +5 -9
  22. package/dist/strategies/columns.js +0 -10
  23. package/dist/strategies/contentReady.d.ts +20 -0
  24. package/dist/strategies/contentReady.js +62 -0
  25. package/dist/strategies/filter.d.ts +0 -4
  26. package/dist/strategies/filter.js +0 -16
  27. package/dist/strategies/headers.d.ts +5 -0
  28. package/dist/strategies/headers.js +17 -9
  29. package/dist/strategies/index.d.ts +23 -15
  30. package/dist/strategies/index.js +9 -5
  31. package/dist/strategies/pagination.js +11 -2
  32. package/dist/strategies/viewport.d.ts +4 -1
  33. package/dist/strategies/viewport.js +62 -32
  34. package/dist/typeContext.d.ts +1 -1
  35. package/dist/typeContext.js +45 -1
  36. package/dist/types.d.ts +43 -1
  37. package/dist/useTable.js +105 -43
  38. package/dist/utils/elementTracker.js +6 -3
  39. package/dist/utils/resolveCellLocator.d.ts +35 -0
  40. package/dist/utils/resolveCellLocator.js +52 -0
  41. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContentReadyStrategies = void 0;
4
+ exports.ContentReadyStrategies = {
5
+ /**
6
+ * Polls the row's text content until two consecutive reads match.
7
+ */
8
+ textStable: (options = {}) => {
9
+ var _a, _b;
10
+ const timeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : 500;
11
+ const interval = (_b = options.interval) !== null && _b !== void 0 ? _b : 50;
12
+ return async (row, page) => {
13
+ const deadline = Date.now() + timeout;
14
+ const remaining = () => Math.max(0, deadline - Date.now());
15
+ let prev = await row.innerText({ timeout: remaining() || timeout });
16
+ while (remaining() > 0) {
17
+ await page.waitForTimeout(Math.min(interval, remaining()));
18
+ if (remaining() <= 0)
19
+ break;
20
+ const cur = await row.innerText({ timeout: remaining() });
21
+ if (cur === prev)
22
+ return;
23
+ prev = cur;
24
+ }
25
+ };
26
+ },
27
+ /**
28
+ * Uses MutationObserver to wait for DOM mutations on the row's subtree
29
+ * to settle. Resolves when no mutations occur for `quietPeriod` ms, or
30
+ * when `timeout` expires.
31
+ */
32
+ mutationSettled: (options = {}) => {
33
+ var _a, _b;
34
+ const timeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : 500;
35
+ const quietPeriod = (_b = options.quietPeriod) !== null && _b !== void 0 ? _b : 100;
36
+ return async (row) => {
37
+ await row.evaluate((el, opts) => {
38
+ return new Promise((resolve) => {
39
+ let quietTimer;
40
+ let deadlineTimer;
41
+ const done = () => {
42
+ clearTimeout(quietTimer);
43
+ clearTimeout(deadlineTimer);
44
+ observer.disconnect();
45
+ resolve();
46
+ };
47
+ const observer = new MutationObserver(() => {
48
+ clearTimeout(quietTimer);
49
+ quietTimer = setTimeout(done, opts.quietPeriod);
50
+ });
51
+ observer.observe(el, {
52
+ childList: true,
53
+ subtree: true,
54
+ characterData: true,
55
+ });
56
+ quietTimer = setTimeout(done, opts.quietPeriod);
57
+ deadlineTimer = setTimeout(done, opts.timeout);
58
+ });
59
+ }, { timeout, quietPeriod });
60
+ };
61
+ },
62
+ };
@@ -2,12 +2,8 @@ import type { FilterStrategy } from '../types';
2
2
  /**
3
3
  * Example filter strategies.
4
4
  * - default: small convenience wrapper that mirrors the engine's default behavior.
5
- * - spy(factory): returns a strategy that marks `calledRef.called = true` when invoked.
6
5
  */
7
6
  export declare const FilterStrategies: {
8
7
  default: FilterStrategy;
9
- spy: (calledRef?: {
10
- called?: boolean;
11
- }) => FilterStrategy;
12
8
  };
13
9
  export type { FilterStrategy } from '../types';
@@ -4,7 +4,6 @@ exports.FilterStrategies = void 0;
4
4
  /**
5
5
  * Example filter strategies.
6
6
  * - default: small convenience wrapper that mirrors the engine's default behavior.
7
- * - spy(factory): returns a strategy that marks `calledRef.called = true` when invoked.
8
7
  */
9
8
  exports.FilterStrategies = {
10
9
  default: {
@@ -20,19 +19,4 @@ exports.FilterStrategies = {
20
19
  return rows.filter({ has: targetCell.getByText(textVal, { exact: true }) });
21
20
  }
22
21
  },
23
- spy: (calledRef = {}) => ({
24
- apply({ rows, filter, colIndex, tableContext }) {
25
- calledRef.called = true;
26
- // Delegate to default behaviour for actual filtering
27
- const page = tableContext.page;
28
- const resolve = tableContext.resolve;
29
- const cellTemplate = resolve(tableContext.config.cellSelector, page);
30
- const targetCell = cellTemplate.nth(colIndex);
31
- if (typeof filter.value === 'function') {
32
- return rows.filter({ has: filter.value(targetCell) });
33
- }
34
- const textVal = typeof filter.value === 'number' ? String(filter.value) : filter.value;
35
- return rows.filter({ has: targetCell.getByText(textVal, { exact: true }) });
36
- }
37
- }),
38
22
  };
@@ -13,6 +13,11 @@ export declare const HeaderStrategies: {
13
13
  /**
14
14
  * Physically scrolls the table horizontally to force virtualized columns to mount,
15
15
  * collecting their names along the way.
16
+ *
17
+ * **Requires `selector`** — the CSS selector for the horizontal scroll container
18
+ * (e.g. `.dvn-scroller` for Glide, `.rdg-viewport` for RDG). Framework class names
19
+ * belong in presets / your config, not in this generic factory. Without `selector`,
20
+ * only currently visible headers are returned (no scrolling).
16
21
  */
17
22
  horizontalScroll: (options?: {
18
23
  limit?: number;
@@ -22,6 +22,11 @@ exports.HeaderStrategies = {
22
22
  /**
23
23
  * Physically scrolls the table horizontally to force virtualized columns to mount,
24
24
  * collecting their names along the way.
25
+ *
26
+ * **Requires `selector`** — the CSS selector for the horizontal scroll container
27
+ * (e.g. `.dvn-scroller` for Glide, `.rdg-viewport` for RDG). Framework class names
28
+ * belong in presets / your config, not in this generic factory. Without `selector`,
29
+ * only currently visible headers are returned (no scrolling).
25
30
  */
26
31
  horizontalScroll: (options) => {
27
32
  return async (context) => {
@@ -37,19 +42,22 @@ exports.HeaderStrategies = {
37
42
  };
38
43
  let currentHeaders = await getVisible();
39
44
  currentHeaders.forEach(h => collectedHeaders.add(h));
40
- const scrollerHandle = await root.evaluateHandle((el, selector) => {
41
- if (selector && el.matches(selector))
45
+ const selector = options === null || options === void 0 ? void 0 : options.selector;
46
+ if (!selector) {
47
+ (0, debugUtils_1.logDebug)(config, 'info', 'HeaderStrategies.horizontalScroll: no selector provided — returning visible headers only. Pass { selector } for the scroll container (e.g. ".dvn-scroller").');
48
+ return Array.from(collectedHeaders);
49
+ }
50
+ const scrollerHandle = await root.evaluateHandle((el, sel) => {
51
+ if (el.matches(sel))
42
52
  return el;
43
- // Try finding common scrollable containers or fallback to root
44
- const effectiveSelector = selector || '.dvn-scroller, .rdg-viewport, [role="grid"]';
45
- const ancestor = el.closest(effectiveSelector);
53
+ const ancestor = el.closest(sel);
46
54
  if (ancestor)
47
55
  return ancestor;
48
- const child = el.querySelector(effectiveSelector);
56
+ const child = el.querySelector(sel);
49
57
  if (child)
50
58
  return child;
51
- return el;
52
- }, options === null || options === void 0 ? void 0 : options.selector);
59
+ return null;
60
+ }, selector);
53
61
  const isScrollerFound = await scrollerHandle.evaluate(el => !!el);
54
62
  if (isScrollerFound) {
55
63
  await scrollerHandle.evaluate(el => el.scrollLeft = 0);
@@ -71,7 +79,7 @@ exports.HeaderStrategies = {
71
79
  }
72
80
  }
73
81
  else {
74
- (0, debugUtils_1.logDebug)(config, 'info', "HeaderStrategies.horizontalScroll: Could not find scroller. Returning visible headers.");
82
+ (0, debugUtils_1.logDebug)(config, 'info', `HeaderStrategies.horizontalScroll: Could not find scroller matching "${selector}". Returning visible headers.`);
75
83
  }
76
84
  if (isScrollerFound) {
77
85
  await scrollerHandle.evaluate(el => el.scrollLeft = 0);
@@ -3,12 +3,19 @@ export * from './sorting';
3
3
  export * from './columns';
4
4
  export * from './headers';
5
5
  export * from './fill';
6
- export * from './resolution';
6
+ export type { ColumnResolutionStrategy } from './resolution';
7
7
  export * from './dedupe';
8
8
  export * from './loading';
9
9
  export * from './stabilization';
10
10
  export * from './filter';
11
11
  export * from './viewport';
12
+ export * from './contentReady';
13
+ /**
14
+ * Built-in strategy factories.
15
+ * @note `CellNavigation` / `Resolution` removed from this namespace in #428 (dead/no-op).
16
+ * Use `strategies.navigation` (NavigationPrimitives) and header maps instead.
17
+ * `Filter.spy` was test-only and is no longer shipped.
18
+ */
12
19
  export declare const Strategies: {
13
20
  Pagination: {
14
21
  click: (selectors: {
@@ -26,21 +33,18 @@ export declare const Strategies: {
26
33
  detectCurrentPage?: (root: import("@playwright/test").Locator) => number | Promise<number>;
27
34
  stabilization?: import("./stabilization").StabilizationStrategy;
28
35
  timeout?: number;
29
- }) => import("../types").PaginationStrategy;
36
+ }) => import("..").PaginationStrategy;
30
37
  infiniteScroll: (options?: {
31
38
  action?: "scroll" | "js-scroll";
32
39
  scrollTarget?: import("..").Selector;
33
40
  scrollAmount?: number;
34
41
  stabilization?: import("./stabilization").StabilizationStrategy;
35
42
  timeout?: number;
36
- }) => import("../types").PaginationStrategy;
43
+ }) => import("..").PaginationStrategy;
37
44
  };
38
45
  Sorting: {
39
46
  AriaSort: () => import("..").SortingStrategy;
40
47
  };
41
- CellNavigation: {
42
- default: () => Promise<void>;
43
- };
44
48
  Header: {
45
49
  visible: ({ config, resolve, root }: import("..").StrategyContext) => Promise<string[]>;
46
50
  horizontalScroll: (options?: {
@@ -50,13 +54,10 @@ export declare const Strategies: {
50
54
  }) => import("./headers").HeaderStrategy;
51
55
  };
52
56
  Fill: {
53
- default: ({ row, columnName, value, fillOptions, config, table }: Parameters<import("../types").FillStrategy>[0]) => Promise<void>;
54
- };
55
- Resolution: {
56
- default: import("./resolution").ColumnResolutionStrategy;
57
+ default: ({ row, columnName, value, fillOptions, config, table }: Parameters<import("..").FillStrategy>[0]) => Promise<void>;
57
58
  };
58
59
  Dedupe: {
59
- byTopPosition: (tolerance?: number) => import("../types").DedupeStrategy;
60
+ byTopPosition: (tolerance?: number) => import("..").DedupeStrategy;
60
61
  };
61
62
  Loading: {
62
63
  Table: {
@@ -93,11 +94,18 @@ export declare const Strategies: {
93
94
  };
94
95
  Filter: {
95
96
  default: import("./filter").FilterStrategy;
96
- spy: (calledRef?: {
97
- called?: boolean;
98
- }) => import("./filter").FilterStrategy;
99
97
  };
100
98
  Viewport: {
101
- dataAttribute: (options?: import("./viewport").DataAttributeViewportOptions) => import("../types").ViewportStrategy;
99
+ dataAttribute: (options?: import("./viewport").DataAttributeViewportOptions) => import("..").ViewportStrategy;
100
+ };
101
+ ContentReady: {
102
+ textStable: (options?: {
103
+ timeout?: number;
104
+ interval?: number;
105
+ }) => import("./contentReady").ContentReadyStrategy;
106
+ mutationSettled: (options?: {
107
+ timeout?: number;
108
+ quietPeriod?: number;
109
+ }) => import("./contentReady").ContentReadyStrategy;
102
110
  };
103
111
  };
@@ -17,36 +17,40 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.Strategies = void 0;
18
18
  const pagination_1 = require("./pagination");
19
19
  const sorting_1 = require("./sorting");
20
- const columns_1 = require("./columns");
21
20
  const headers_1 = require("./headers");
22
21
  const fill_1 = require("./fill");
23
- const resolution_1 = require("./resolution");
24
22
  const dedupe_1 = require("./dedupe");
25
23
  const loading_1 = require("./loading");
26
24
  const stabilization_1 = require("./stabilization");
27
25
  const filter_1 = require("./filter");
28
26
  const viewport_1 = require("./viewport");
27
+ const contentReady_1 = require("./contentReady");
29
28
  __exportStar(require("./pagination"), exports);
30
29
  __exportStar(require("./sorting"), exports);
31
30
  __exportStar(require("./columns"), exports);
32
31
  __exportStar(require("./headers"), exports);
33
32
  __exportStar(require("./fill"), exports);
34
- __exportStar(require("./resolution"), exports);
35
33
  __exportStar(require("./dedupe"), exports);
36
34
  __exportStar(require("./loading"), exports);
37
35
  __exportStar(require("./stabilization"), exports);
38
36
  __exportStar(require("./filter"), exports);
39
37
  __exportStar(require("./viewport"), exports);
38
+ __exportStar(require("./contentReady"), exports);
39
+ /**
40
+ * Built-in strategy factories.
41
+ * @note `CellNavigation` / `Resolution` removed from this namespace in #428 (dead/no-op).
42
+ * Use `strategies.navigation` (NavigationPrimitives) and header maps instead.
43
+ * `Filter.spy` was test-only and is no longer shipped.
44
+ */
40
45
  exports.Strategies = {
41
46
  Pagination: pagination_1.PaginationStrategies,
42
47
  Sorting: sorting_1.SortingStrategies,
43
- CellNavigation: columns_1.CellNavigationStrategies,
44
48
  Header: headers_1.HeaderStrategies,
45
49
  Fill: fill_1.FillStrategies,
46
- Resolution: resolution_1.ResolutionStrategies,
47
50
  Dedupe: dedupe_1.DedupeStrategies,
48
51
  Loading: loading_1.LoadingStrategies,
49
52
  Stabilization: stabilization_1.StabilizationStrategies,
50
53
  Filter: filter_1.FilterStrategies,
51
54
  Viewport: viewport_1.ViewportStrategies,
55
+ ContentReady: contentReady_1.ContentReadyStrategies,
52
56
  };
@@ -83,6 +83,7 @@ exports.PaginationStrategies = {
83
83
  const scrollTarget = options.scrollTarget
84
84
  ? resolve(options.scrollTarget, root)
85
85
  : root;
86
+ const beforeScrollTop = await scrollTarget.evaluate((el) => el.scrollTop);
86
87
  const doScroll = async () => {
87
88
  const box = await scrollTarget.boundingBox();
88
89
  const scrollValue = amount * directionMultiplier;
@@ -98,8 +99,16 @@ exports.PaginationStrategies = {
98
99
  await page.mouse.wheel(0, scrollValue);
99
100
  }
100
101
  };
101
- // Stabilization: Wait
102
- return await stabilization(context, doScroll);
102
+ // Stabilization: Wait for content to settle
103
+ const stabilizationResult = await stabilization(context, doScroll);
104
+ // EOF detection: use scroll position, not stabilization result.
105
+ // Stabilization may time out for recycling virtualizers when the scroll
106
+ // amount is small enough to stay within the overscan window (no new rows
107
+ // rendered), but the scroll position still changed — we haven't reached
108
+ // the end of the data.
109
+ const afterScrollTop = await scrollTarget.evaluate((el) => el.scrollTop);
110
+ const scrollMoved = Math.abs(afterScrollTop - beforeScrollTop) >= 1;
111
+ return scrollMoved || stabilizationResult;
103
112
  };
104
113
  };
105
114
  const createGoToFirst = () => {
@@ -3,7 +3,10 @@ export type DataAttributeViewportOptions = {
3
3
  /**
4
4
  * CSS selector for the scroll container (the element with overflow: auto/scroll).
5
5
  * Resolved with `closest()` from the root element, so ancestors are found automatically.
6
- * Defaults to 'div[class*="overflow-auto"]'.
6
+ *
7
+ * **No framework default** — pass the selector for your table (e.g. Tailwind
8
+ * `div[class*="overflow-auto"]`, MUI `.MuiDataGrid-virtualScroller`). Omitting it
9
+ * means geometry filtering treats all mounted rows as visible and `scrollTo*` is a no-op.
7
10
  */
8
11
  scrollContainer?: string;
9
12
  /**
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ViewportStrategies = void 0;
4
+ const debugUtils_1 = require("../utils/debugUtils");
4
5
  /**
5
6
  * Viewport strategies for tables where row and cell elements carry a DOM attribute
6
7
  * equal to their index (e.g. Braintrust logs table, TanStack-style grids).
@@ -11,17 +12,30 @@ exports.ViewportStrategies = void 0;
11
12
  * - scrollToColumn — aligns header's left edge to container left, waits for cell mount
12
13
  * - scrollToRow — scrolls row into view, waits for row mount
13
14
  *
15
+ * **Important:** `rowAttribute` must be present on the elements matched by `rowSelector`,
16
+ * not on child cells. If row elements lack the attribute, `getVisibleRowRange` returns
17
+ * `{first:0, last:0}` and logs a warning at the `error` log level.
18
+ *
14
19
  * @example
15
- * // TanStack / Braintrust (data-index, 0-based — default)
16
- * viewport: Strategies.Viewport.dataAttribute()
20
+ * // TanStack / Braintrust — pass your scroll container explicitly (no Tailwind default)
21
+ * viewport: Strategies.Viewport.dataAttribute({
22
+ * scrollContainer: 'div[class*="overflow-auto"]',
23
+ * })
17
24
  *
18
25
  * @example
19
26
  * // ARIA grid (aria-rowindex / aria-colindex, 1-based)
20
- * viewport: Strategies.Viewport.dataAttribute({ rowAttribute: 'aria-rowindex', columnAttribute: 'aria-colindex', rowOffset: 1, columnOffset: 1 })
27
+ * viewport: Strategies.Viewport.dataAttribute({
28
+ * scrollContainer: '.MuiDataGrid-virtualScroller',
29
+ * rowAttribute: 'aria-rowindex',
30
+ * columnAttribute: 'aria-colindex',
31
+ * rowOffset: 1,
32
+ * columnOffset: 1,
33
+ * })
21
34
  */
22
35
  const dataAttribute = (options) => {
23
36
  var _a, _b, _c, _d, _e, _f, _g;
24
- const containerSel = (_a = options === null || options === void 0 ? void 0 : options.scrollContainer) !== null && _a !== void 0 ? _a : 'div[class*="overflow-auto"]';
37
+ // null = no container: geometry filter keeps all mounted rows; scrollTo* no-ops (#431)
38
+ const containerSel = (_a = options === null || options === void 0 ? void 0 : options.scrollContainer) !== null && _a !== void 0 ? _a : null;
25
39
  const attachTimeout = (_b = options === null || options === void 0 ? void 0 : options.attachTimeout) !== null && _b !== void 0 ? _b : 3000;
26
40
  const rowAttr = (_c = options === null || options === void 0 ? void 0 : options.rowAttribute) !== null && _c !== void 0 ? _c : 'data-index';
27
41
  const colAttr = (_d = options === null || options === void 0 ? void 0 : options.columnAttribute) !== null && _d !== void 0 ? _d : 'data-index';
@@ -34,17 +48,22 @@ const dataAttribute = (options) => {
34
48
  getVisibleColumnRange: async ({ root, config }) => {
35
49
  const rowSel = config.rowSelector;
36
50
  const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : `[${colAttr}]`;
37
- return root.evaluate((el, { rowSel, cellSel, colAttr, colOffset }) => {
51
+ const result = await root.evaluate((el, { rowSel, cellSel, colAttr, colOffset }) => {
38
52
  const firstRow = el.querySelector(rowSel);
39
53
  if (!firstRow)
40
- return { first: 0, last: 0 };
41
- const indices = Array.from(firstRow.querySelectorAll(cellSel))
54
+ return { first: 0, last: 0, _cellCount: 0, _validCount: 0 };
55
+ const cells = Array.from(firstRow.querySelectorAll(cellSel));
56
+ const indices = cells
42
57
  .map(c => Number(c.getAttribute(colAttr)) - colOffset)
43
58
  .filter(n => !isNaN(n));
44
59
  if (!indices.length)
45
- return { first: 0, last: 0 };
46
- return { first: Math.min(...indices), last: Math.max(...indices) };
60
+ return { first: 0, last: 0, _cellCount: cells.length, _validCount: 0 };
61
+ return { first: Math.min(...indices), last: Math.max(...indices), _cellCount: cells.length, _validCount: indices.length };
47
62
  }, { rowSel, cellSel, colAttr, colOffset });
63
+ if (result._cellCount > 0 && result._validCount === 0) {
64
+ (0, debugUtils_1.logDebug)(config, 'error', `dataAttribute viewport: ${result._cellCount} cell(s) found but none have attribute "${colAttr}". Check that columnAttribute targets cell elements matched by cellSelector.`);
65
+ }
66
+ return { first: result.first, last: result.last };
48
67
  },
49
68
  getVisibleRowIndices: async ({ root, config }) => {
50
69
  const rowSel = config.rowSelector;
@@ -52,7 +71,9 @@ const dataAttribute = (options) => {
52
71
  // the rows' DOM positions so the iteration engine can drop overscan rows (#353/#357).
53
72
  return root.evaluate((el, { rowSel, containerSel }) => {
54
73
  const rows = Array.from(el.querySelectorAll(rowSel));
55
- const container = el.closest(containerSel);
74
+ const container = containerSel
75
+ ? el.closest(containerSel)
76
+ : null;
56
77
  const containerRect = container ? container.getBoundingClientRect() : null;
57
78
  const visible = [];
58
79
  rows.forEach((r, i) => {
@@ -72,41 +93,42 @@ const dataAttribute = (options) => {
72
93
  },
73
94
  getVisibleRowRange: async ({ root, config }) => {
74
95
  const rowSel = config.rowSelector;
75
- return root.evaluate((el, { rowSel, rowAttr, rowOffset, containerSel }) => {
96
+ const result = await root.evaluate((el, { rowSel, rowAttr, rowOffset, containerSel }) => {
76
97
  const rows = Array.from(el.querySelectorAll(rowSel));
77
- // #353: report only rows actually within the scroll container's vertical
78
- // bounds, not every mounted row. Previously overscan rows (kept mounted by
79
- // the virtual scroller above/below the fold) were counted as "visible".
80
- // Inclusive: a row with ANY vertical overlap counts as visible; only rows
81
- // entirely above or below the container are dropped — so we never drop a
82
- // partially-visible real row.
83
- const container = el.closest(containerSel);
98
+ const container = containerSel
99
+ ? el.closest(containerSel)
100
+ : null;
84
101
  const containerRect = container ? container.getBoundingClientRect() : null;
85
- const indices = rows
102
+ const visibleRows = rows
86
103
  .filter(r => {
87
- // Container not resolvable — can't measure, so keep all rows (safe
88
- // fallback, preserves pre-#353 behavior rather than risk dropping real rows).
89
104
  if (!containerRect)
90
105
  return true;
91
106
  const rect = r.getBoundingClientRect();
92
107
  if (rect.height === 0)
93
- return false; // unrendered / detached
108
+ return false;
94
109
  return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
95
- })
110
+ });
111
+ const indices = visibleRows
96
112
  .map(r => Number(r.getAttribute(rowAttr)) - rowOffset)
97
113
  .filter(n => !isNaN(n));
98
114
  if (!indices.length)
99
- return { first: 0, last: 0 };
100
- return { first: Math.min(...indices), last: Math.max(...indices) };
115
+ return { first: 0, last: 0, _rowCount: visibleRows.length, _validCount: 0 };
116
+ return { first: Math.min(...indices), last: Math.max(...indices), _rowCount: visibleRows.length, _validCount: indices.length };
101
117
  }, { rowSel, rowAttr, rowOffset, containerSel });
118
+ if (result._rowCount > 0 && result._validCount === 0) {
119
+ (0, debugUtils_1.logDebug)(config, 'error', `dataAttribute viewport: ${result._rowCount} row(s) found but none have attribute "${rowAttr}". Check that rowAttribute targets row elements matched by rowSelector, not child cells. getVisibleRowRange returning {first:0, last:0}.`);
120
+ }
121
+ return { first: result.first, last: result.last };
102
122
  },
103
123
  scrollToColumn: async ({ root, config }, colIndex) => {
104
124
  const headerSel = typeof config.headerSelector === 'string' ? config.headerSelector : null;
125
+ if (!containerSel || !headerSel)
126
+ return;
105
127
  const cellSel = typeof config.cellSelector === 'string' ? config.cellSelector : `[${colAttr}]`;
106
- await root.evaluate((el, { containerSel, headerSel, cellSel, idx, columnWidth, scrollPadding }) => {
128
+ const canScroll = await root.evaluate((el, { containerSel, headerSel, cellSel, idx, columnWidth, scrollPadding }) => {
107
129
  const container = el.closest(containerSel);
108
- if (!container || !headerSel)
109
- return;
130
+ if (!container)
131
+ return false;
110
132
  const headers = Array.from(el.querySelectorAll(headerSel));
111
133
  const target = headers[idx];
112
134
  if (!target) {
@@ -118,7 +140,7 @@ const dataAttribute = (options) => {
118
140
  ? widths.reduce((sum, width) => sum + width, 0) / widths.length
119
141
  : 120);
120
142
  container.scrollLeft = Math.max(0, idx * estimatedWidth - scrollPadding);
121
- return;
143
+ return true;
122
144
  }
123
145
  const cRect = container.getBoundingClientRect();
124
146
  const tRect = target.getBoundingClientRect();
@@ -130,7 +152,10 @@ const dataAttribute = (options) => {
130
152
  // Scrolling right: reveal the target's right edge with padding.
131
153
  container.scrollLeft += (tRect.right - cRect.right) + scrollPadding;
132
154
  }
155
+ return true;
133
156
  }, { containerSel, headerSel, cellSel, idx: colIndex, columnWidth, scrollPadding });
157
+ if (!canScroll)
158
+ return;
134
159
  // Wait for a cell at this column index to mount in any row
135
160
  await root
136
161
  .locator(`${config.rowSelector} [${colAttr}="${colIndex + colOffset}"]`)
@@ -138,15 +163,17 @@ const dataAttribute = (options) => {
138
163
  .waitFor({ state: 'attached', timeout: attachTimeout });
139
164
  },
140
165
  scrollToRow: async ({ root, config }, rowIndex) => {
166
+ if (!containerSel)
167
+ return;
141
168
  const rowSel = config.rowSelector;
142
- await root.evaluate((el, { containerSel, rowSel, rowAttr, idx, rowOffset, rowHeight, scrollPadding }) => {
169
+ const canScroll = await root.evaluate((el, { containerSel, rowSel, rowAttr, idx, rowOffset, rowHeight, scrollPadding }) => {
143
170
  const container = el.closest(containerSel);
144
171
  if (!container)
145
- return;
172
+ return false;
146
173
  const row = container.querySelector(`${rowSel}[${rowAttr}="${idx + rowOffset}"]`);
147
174
  if (row) {
148
175
  row.scrollIntoView({ block: 'nearest', inline: 'nearest' });
149
- return;
176
+ return true;
150
177
  }
151
178
  const visibleRows = Array.from(container.querySelectorAll(rowSel));
152
179
  const heights = visibleRows
@@ -156,7 +183,10 @@ const dataAttribute = (options) => {
156
183
  ? heights.reduce((sum, height) => sum + height, 0) / heights.length
157
184
  : 40);
158
185
  container.scrollTop = Math.max(0, idx * estimatedHeight - scrollPadding);
186
+ return true;
159
187
  }, { containerSel, rowSel, rowAttr, idx: rowIndex, rowOffset, rowHeight, scrollPadding });
188
+ if (!canScroll)
189
+ return;
160
190
  await root
161
191
  .locator(`${rowSel}[${rowAttr}="${rowIndex + rowOffset}"]`)
162
192
  .waitFor({ state: 'attached', timeout: attachTimeout });