Skip to content

Strategies

Strategies define how the library interacts with different table implementations. They handle pagination, sorting, header scanning, cell resolution, and more.

Strategy Types & Usage

Strategy TypeUsed By MethodsDescription
PaginationfindRow(), findRows(), forEach, map, filterNavigating to next pages
Sortingsorting.apply()Applying sort order
Fillrow.smartFill()Entering data into cells
Headerinit(), revalidate()Finding and parsing column headers
Cell LocatorgetCell(), toJSON()Custom cell resolution within a row
ViewportgetCell(), bringIntoView()Recovering rows/columns in 2D virtualized grids

Overview

typescript
import { useTable, Strategies } from '@rickcedwhat/playwright-smart-table';

const table = useTable(page.locator('#table'), {
  strategies: {
    pagination: Strategies.Pagination.click({ next: '.next-btn' }),
    sorting: Strategies.Sorting.AriaSort(),
  }
});

Pagination Strategies

Control how the library navigates through pages.

Strategies.Pagination.click(selectors, options?)

Click pagination buttons (Next, Previous, First, bulk jump). The built-in strategy handles stabilization automatically.

typescript
strategies: {
  pagination: Strategies.Pagination.click({
    next: '.pagination .next',      // required for forward navigation
    previous: '.pagination .prev',  // optional — enables bringIntoView backward nav
    first: '.pagination .first',    // optional — enables reset() auto-nav to page 1
    nextBulk: '.pagination .skip',  // optional — jumps N pages at once
  }, {
    nextBulkPages: 10,  // how many pages nextBulk advances (default: 10)
  })
}

Selectors can be a CSS string, a function returning a Locator, or a Locator directly.

Strategies.Pagination.infiniteScroll(options?)

Handle infinite scroll tables (append-only or virtualized).

typescript
strategies: {
  pagination: Strategies.Pagination.infiniteScroll({
    action: 'js-scroll',          // 'scroll' (mouse wheel) or 'js-scroll' (scrollTop)
    scrollAmount: 500,            // pixels per step
    scrollTarget: (root) => root, // defaults to table root
    stabilization: Strategies.Stabilization.rowCountIncreased({ timeout: 2000 })
  })
}

Custom Pagination

Implement goNext (and optionally goPrevious, goToFirst, goToPage) directly as async functions. Each returns true if navigation succeeded, false if there are no more pages.

typescript
strategies: {
  pagination: {
    goNext: async ({ root, page }) => {
      const btn = page.locator('.custom-next');
      if (await btn.isDisabled()) return false;
      await btn.click();
      return true;
    },
    goPrevious: async ({ root, page }) => {
      const btn = page.locator('.custom-prev');
      if (await btn.isDisabled()) return false;
      await btn.click();
      return true;
    },
    goToFirst: async ({ root, page }) => {
      await page.locator('.custom-first').click();
      return true;
    }
  }
}

NOTE

The context object provides { root, page, config, resolve }. Use root (not rootLocator) to access the table's root locator.


Sorting Strategies

Strategies.Sorting.AriaSort()

The built-in strategy. Clicks the column header to trigger sorting and reads the aria-sort attribute (ascending/descending) to detect the current state. The library handles retry logic — your doSort only needs to issue the click.

typescript
strategies: {
  sorting: Strategies.Sorting.AriaSort()
}

Custom Sorting

Implement doSort (trigger) and getSortState (state detection). The library calls doSort and then polls getSortState until the target state is reached (up to 3 retries).

typescript
strategies: {
  sorting: {
    doSort: async ({ columnName, direction, context }) => {
      // Only issue the trigger — the library handles retry/verification
      const header = await context.getHeaderCell(columnName);
      await header.click();
    },
    getSortState: async ({ columnName, context }) => {
      const header = await context.getHeaderCell(columnName);
      const cls = await header.getAttribute('class') ?? '';
      if (cls.includes('sort-asc')) return 'asc';
      if (cls.includes('sort-desc')) return 'desc';
      return 'none';
    }
  }
}

Fill Strategy

Default (Auto-Detection)

smartFill() uses a built-in auto-detection strategy by default — it detects <input>, <select>, <textarea>, checkbox, and contenteditable elements automatically. No configuration needed in most cases.

typescript
await row.smartFill({
  Name: 'John',        // fills <input type="text">
  Status: 'Active',    // selects <option> in <select>
  Active: true,        // checks/unchecks <input type="checkbox">
  Notes: 'Some text'  // fills <textarea>
});

Custom Fill via columnOverrides.write

For cells with nonstandard input widgets (e.g., a rich-text editor, a date picker with a custom popover), define a write handler in columnOverrides:

typescript
columnOverrides: {
  Tags: {
    write: async ({ cell, targetValue }) => {
      // e.g. a tag input: type and press Enter
      await cell.locator('.tag-input').click();
      await cell.page().keyboard.type(targetValue);
      await cell.page().keyboard.press('Enter');
    }
  },
  Price: {
    write: async ({ cell, targetValue, currentValue }) => {
      // currentValue is automatically provided when `read` is also defined
      if (currentValue === targetValue) return;
      await cell.locator('input').fill(String(targetValue));
    }
  }
}

Custom Fill via inputMappers

For one-off overrides at call time (without modifying config), use inputMappers in the smartFill call:

typescript
await row.smartFill(
  { Name: 'John' },
  {
    inputMappers: {
      // Target a specific input inside the cell
      Name: (cell) => cell.locator('.primary-input')
    }
  }
);

Header Strategy

Default

The default strategy reads visible thead th text (or whatever headerSelector resolves to) using innerText.

Custom Header Strategy

Provide a function that returns Promise<string[]>. The array must be in DOM order (index 0 = first column).

typescript
strategies: {
  header: async ({ root, resolve, config }) => {
    // Example: read from aria-label attributes instead of text
    const headers = await resolve(config.headerSelector, root).all();
    return Promise.all(
      headers.map(h => h.getAttribute('aria-label').then(v => v ?? ''))
    );
  }
}

Bulk header read with evaluateAll

When header names live in DOM attributes (not visible text), evaluateAll reads all of them in a single browser round-trip instead of one getAttribute call per header — useful when your table has many columns or initializes repeatedly in a test suite.

typescript
strategies: {
  header: async ({ root, resolve, config }) => {
    // Single round-trip: extract all column keys from data attributes at once
    return resolve(config.headerSelector, root).evaluateAll(
      (els) => els.map(el => el.getAttribute('data-column-key') ?? el.textContent?.trim() ?? '')
    );
  }
}

The callback runs inside the browser, so you can derive column names from any combination of attributes, computed styles, or text — without a waterfall of individual Playwright calls.

TIP

Fall back to textContent (as shown above) to gracefully handle any header elements that don't carry the expected attribute.


Cell Locator Strategy

Default

The default strategy resolves cells using .nth(columnIndex) on the cellSelector within a row.

Custom Cell Locator

When the DOM doesn't use predictable nth-child ordering (e.g. horizontally virtualized grids using aria-colindex), provide a getCellLocator function:

typescript
strategies: {
  getCellLocator: ({ row, columnName, columnIndex, root, page, config }) => {
    // e.g. react-data-grid with aria-colindex attributes
    return row.locator(`[aria-colindex="${columnIndex}"]`);
  }
}

The function receives { row, root, columnName, columnIndex, rowIndex?, page, config } and returns a Locator.


beforeCellRead Hook

beforeCellRead is called once per cell immediately before the cell value is read — in both toJSON() and any columnOverrides.read calls. Use it to handle cases where the cell content is not yet in a readable state when the row becomes visible.

Signature

typescript
strategies: {
  beforeCellRead: async ({ cell, columnName, columnIndex, row, page, root, getHeaderCell }) => {
    // scroll, hover, wait — whatever the cell needs before its value can be read
  }
}

When to use it

Use caseSafe?
Scroll a column header into view on a grid with column-only (X-axis) virtualization✅ Safe
Hover a cell to trigger a tooltip / popover, then wait for it to render✅ Safe
Wait for an async cell renderer to finish loading✅ Safe
Call scrollIntoViewIfNeeded() on any element in a row-virtualized grid❌ Footgun

Column-only horizontal virtualization

For grids that keep all rows in the DOM but only render visible columns, scrolling the header into view forces the column to mount:

typescript
strategies: {
  beforeCellRead: async ({ columnName, getHeaderCell }) => {
    const header = await getHeaderCell(columnName);
    await header.scrollIntoViewIfNeeded();
  }
}

Lazy-rendered cell content (popovers, tooltips)

typescript
strategies: {
  beforeCellRead: async ({ cell, page }) => {
    await cell.hover();
    await page.waitForSelector('.cell-tooltip', { state: 'visible' });
  }
}

⚠️ Y-scroll footgun on row-virtualized grids

scrollIntoViewIfNeeded adjusts both scroll axes. On grids that recycle DOM nodes based on the Y scroll position (MUI DataGrid, AG Grid, react-window, etc.) calling it inside beforeCellRead will shift the viewport vertically, unmounting the row currently being read and silently returning stale or empty values for the rest of the row.

Do not use beforeCellRead for Y-axis scrolling. Instead, configure the viewport strategy — it drives explicit per-row and per-column scroll commands and understands the virtualization lifecycle.

typescript
// ❌ Breaks row-virtualized grids — Y-scroll unmounts the current row mid-read
strategies: {
  beforeCellRead: async ({ cell }) => {
    await cell.scrollIntoViewIfNeeded(); // scrolls Y → row recycled → empty values
  }
}

// ✅ Correct approach for 2D virtualized grids
strategies: {
  viewport: Strategies.Viewport.dataAttribute({
    rowAttribute: 'data-rowindex',
    columnAttribute: 'data-colindex',
  })
}

Viewport Strategy

Viewport strategies help with 2D virtualized grids where rows and columns are both mounted only when visible. They let the cell-reading engine detect what is currently in the DOM and jump directly to the target row or column when scrolling one axis causes the other axis to unmount.

Strategies.Viewport.dataAttribute(options?)

Use this when row and cell elements expose their indexes through DOM attributes.

typescript
strategies: {
  viewport: Strategies.Viewport.dataAttribute({
    scrollContainer: '.grid-scroll-container',
    rowAttribute: 'data-index',
    columnAttribute: 'data-index'
  })
}

For ARIA grids, account for 1-based indexes:

typescript
strategies: {
  viewport: Strategies.Viewport.dataAttribute({
    rowAttribute: 'aria-rowindex',
    columnAttribute: 'aria-colindex',
    rowOffset: 1,
    columnOffset: 1
  })
}

Custom Viewport Strategy

Provide any combination of range oracles and scroll primitives:

typescript
strategies: {
  viewport: {
    getVisibleRowRange: async ({ root }) => ({ first: 0, last: 20 }),
    getVisibleColumnRange: async ({ root }) => ({ first: 0, last: 8 }),
    scrollToRow: async ({ root }, rowIndex) => {
      await root.evaluate((el, index) => {
        el.querySelector(`[data-row-index="${index}"]`)?.scrollIntoView();
      }, rowIndex);
    },
    scrollToColumn: async ({ root }, columnIndex) => {
      await root.evaluate((el, index) => {
        el.querySelector(`[data-column-index="${index}"]`)?.scrollIntoView();
      }, columnIndex);
    }
  }
}

Complete Example

typescript
import { useTable, Strategies } from '@rickcedwhat/playwright-smart-table';

const table = useTable(page.locator('#complex-table'), {
  headerSelector: 'thead th',
  rowSelector: 'tbody tr',

  strategies: {
    pagination: Strategies.Pagination.click({
      next: '.pagination .next',
      previous: '.pagination .prev',
      first: '.pagination .first',
    }),

    sorting: Strategies.Sorting.AriaSort(),

    getCellLocator: ({ row, columnIndex }) =>
      row.locator(`[data-column-index="${columnIndex}"]`),
  },

  columnOverrides: {
    Status: {
      read: async (cell) => {
        return (await cell.locator('input[type="checkbox"]').isChecked())
          ? 'Active' : 'Inactive';
      },
      write: async ({ cell, targetValue }) => {
        const checkbox = cell.locator('input[type="checkbox"]');
        if ((await checkbox.isChecked()) !== (targetValue === 'Active')) {
          await checkbox.click();
        }
      }
    }
  }
});

await table.init();

Next Steps

See how these strategies are applied in real-world scenarios.

Go to Examples >