Skip to main content
astrocraft-ui/ components · 101

Data Table

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add display/data-table

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add display/data-table --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add display/data-table --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/data-table --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/data-table --theme tailwind --bridge lumos

Live demo

DataTable

A real <table>, so row and column association come from the browser. Click a column head to sort — aria-sort moves to it and the rows are moved, not re-rendered, so the checkboxes keep their state. The header checkbox is the library's third indeterminate case and reuses CheckboxGroup's groupState(). Drag the handle after Client to resize it, or focus it and use the arrow keys.

NorthwindPaid£1,250.002 Sept 2026
AcmeOverdue£480.5014 Aug 2026
InitechDraft£9,600.001 Oct 2026
UmbrellaPaid£75.0019 Sept 2026

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`DataTable.astro``data-table` `data-table-container` `data-table-status`———
`DataTableHead.astro``data-table-head``data-sort`: `none` · `ascending` · `descending`
`data-align`: `start` · `center` · `end`
——
`TableColumnResizer.astro``table-column-resizer`———
`TablePagination.astro``table-pagination` `table-pagination-status`———
`TableRowSelect.astro``table-row-select`———
`TableSelectAll.astro``table-select-all`———
`TableSortButton.astro``table-sort-button`———
`TableToolbar.astro``table-toolbar``data-align`: `start` · `between` · `end`——

Source

What the command copies — 11 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/display/data-table/DataTable.astro
---
// src/components/ui/display/data-table/DataTable.astro — headless primitive (see ../../README.md).
// A real `<table>` that sorts, selects and resizes — the three behaviors people reach for a grid
// library to get, on markup a browser already understands.
//
//   <DataTable label="Invoices">
//     <TableHeader>
//       <TableRow>
//         <DataTableHead><TableSelectAll /></DataTableHead>
//         <DataTableHead sortable><TableSortButton>Client</TableSortButton><TableColumnResizer /></DataTableHead>
//         <DataTableHead sortable><TableSortButton>Amount</TableSortButton></DataTableHead>
//       </TableRow>
//     </TableHeader>
//     <TableBody>
//       <TableRow>
//         <TableCell><TableRowSelect label="Select Acme" /></TableCell>
//         <TableCell>Acme</TableCell>
//         <TableCell data-sort-value="1250">£1,250</TableCell>
//       </TableRow>
//     </TableBody>
//   </DataTable>
//
// It is a `<table>` and not a set of divs with `role="grid"`, and that is the first decision. The
// browser's table semantics — row and column association, header announcement as you move between
// cells, the caption — are the part of a data grid nobody re-implements correctly, and they come
// free only if the markup is a table. `role="grid"` is for the OTHER widget: a spreadsheet you
// navigate cell by cell with the arrow keys. A sortable, selectable list of records is a table.
//
// THREE BEHAVIORS, and each is here because it is genuinely not markup:
//
//   • SORTING. `aria-sort` on the header cell, exactly one non-`none` at a time, rows re-appended in
//     the new order — appended, never re-rendered, so a checked row stays checked and any listener a
//     consumer put on a cell survives. The order comes from `sort.ts`; date and currency columns
//     sort correctly by giving the cell a `data-sort-value` (an ISO date, a raw number) rather than
//     by this library trying to parse "£1,250" or "12/03/26", which is unparseable without knowing
//     the locale.
//   • SELECTION. The third indeterminate case in the library, and it reuses CheckboxGroup's checked
//     `groupState()` rather than a second copy of the same three-line rule.
//   • RESIZING. Pointer AND keyboard — see TableColumnResizer. The first resizer found pins every
//     column's current width and switches the table to `table-layout: fixed`, because in the
//     automatic layout the browser treats a width as a suggestion and will quietly redistribute it.
//     Pinning first is what stops the columns jumping at the moment of the first drag.
//
// Sorting and selection both announce, through one polite region under the table: a sort moves
// nothing and repaints nothing that assistive tech notices, and a select-all changes fifty
// checkboxes silently. The sort says the column and the direction; selection says the count, because
// a number needs no translating.
//
// ponytail: `cellIndex` maps a header to its column, so a `colspan` in the header row mis-maps the
// columns after it. Grouped headers need a header-to-column map built from the spans; nothing in the
// catalogue has one, and guessing at the shape of that API before a real case is how it gets wrong.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"table"> & {
  /** Names the table. A data table with neither a caption nor a name is announced as "table". */
  label?: string;
  /** Announced after a sort, with the column's name. Yours to translate. */
  ascendingLabel?: string;
  descendingLabel?: string;
};

const {
  label,
  ascendingLabel = "ascending",
  descendingLabel = "descending",
  class: className,
  ...rest
} = Astro.props;
---

<div data-slot="data-table-container">
  <table
    aria-label={label}
    class={className}
    data-slot="data-table"
    data-ascending-label={ascendingLabel}
    data-descending-label={descendingLabel}
    {...rest}
  >
    <slot />
  </table>
  {/* The one live region: a sort and a bulk selection are both silent without it. */}
  <span role="status" aria-live="polite" data-slot="data-table-status"></span>
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { groupState } from "../../forms/checkbox-group/group-state";
  import { nextDirection, orderBy } from "./sort";

  const SORT_BUTTON = '[data-slot="table-sort-button"]';
  const RESIZER = '[data-slot="table-column-resizer"]';
  const SELECT_ALL = '[data-slot="table-select-all"]';
  const ROW_SELECT = '[data-slot="table-row-select"]';

  /** Narrowest a column may be dragged — below this a header's own text is unreadable. */
  const MIN_WIDTH = 48;
  /** Pixels per arrow press on a resizer. */
  const KEY_STEP = 16;

  /** What a row sorts by in `column`: the cell's `data-sort-value` if it has one, else its text. */
  function sortValue(row: HTMLTableRowElement, column: number): string {
    const cell = row.cells[column];
    return (cell?.dataset.sortValue ?? cell?.textContent ?? "").trim();
  }

  function wireSort(table: HTMLTableElement, announce: (text: string) => void) {
    table.addEventListener("click", (event) => {
      if (!(event.target instanceof Element)) return;
      const button = event.target.closest<HTMLElement>(SORT_BUTTON);
      const head = button?.closest("th");
      const body = table.tBodies[0];
      if (!button || !head || !body) return;

      const direction = nextDirection(head.getAttribute("aria-sort"));
      // Exactly one column may claim a direction; the rest go back to "none" rather than losing the
      // attribute, which would make them read as "not sortable". See DataTableHead.
      for (const other of table.querySelectorAll("th[aria-sort]")) {
        other.setAttribute("aria-sort", "none");
      }
      head.setAttribute("aria-sort", direction);

      const rows = [...body.rows];
      const column = head.cellIndex;
      // Appending a node that is already in the document MOVES it. No clone, no re-render: every
      // listener, every checked checkbox and every focused element survives the sort.
      for (const i of orderBy(
        rows.map((row) => sortValue(row, column)),
        direction,
      )) {
        body.append(rows[i]);
      }

      const word =
        direction === "ascending" ? table.dataset.ascendingLabel : table.dataset.descendingLabel;
      announce(`${(button.textContent ?? "").trim()}, ${word ?? direction}`);
    });
  }

  function wireSelection(table: HTMLTableElement, announce: (text: string) => void) {
    const all = table.querySelector<HTMLInputElement>(SELECT_ALL);
    const boxes = () => [...table.querySelectorAll<HTMLInputElement>(ROW_SELECT)];
    if (!all && boxes().length === 0) return;

    // Set while the select-all box is writing to every row: each write dispatches a `change` that
    // bubbles back here, and without this the count is recomputed — and announced — once per row.
    let bulk = false;

    const sync = (quiet = false) => {
      const checked = boxes().filter((box) => box.checked);
      if (all) {
        const state = groupState(boxes().map((box) => box.checked));
        all.checked = state === "all";
        // The reason this component needs a script at all: `indeterminate` is a DOM property with no
        // HTML attribute, so the mixed state cannot be written in markup. Same rule as CheckboxGroup.
        all.indeterminate = state === "some";
      }
      // A count rather than a sentence — the one thing that needs no translating (see TagsInput).
      if (!quiet) announce(String(checked.length));
    };

    all?.addEventListener("change", () => {
      // Read ONCE. Each dispatched `change` below re-enters the listener further down and rewrites
      // `all.checked`; re-reading it inside the loop sets the remaining rows to the wrong value.
      const next = all.checked;
      bulk = true;
      for (const box of boxes()) {
        if (box.disabled || box.checked === next) continue;
        box.checked = next;
        // So a consumer's own listener sees every row change, not just the header's click.
        box.dispatchEvent(new Event("change", { bubbles: true }));
      }
      bulk = false;
      sync();
    });

    table.addEventListener("change", (event) => {
      if (bulk) return;
      if (event.target instanceof HTMLInputElement && event.target.matches(ROW_SELECT)) sync();
    });

    // Name the row boxes that were left to us — see TableRowSelect on why fifty "Select row"s is a
    // bug. The row's first cell with text in it is the row's identity in every table anyone writes.
    for (const box of boxes()) {
      if (box.dataset.autoLabel !== "true") continue;
      const row = box.closest("tr");
      const text = [...(row?.cells ?? [])]
        .map((cell) => (cell.textContent ?? "").trim())
        .find((value) => value !== "");
      if (text) box.setAttribute("aria-label", `${box.getAttribute("aria-label")} ${text}`);
    }

    sync(true); // quiet: nothing has changed yet, and a live region that speaks on load is noise
  }

  function wireResizers(table: HTMLTableElement) {
    const handles = [...table.querySelectorAll<HTMLElement>(RESIZER)];
    if (handles.length === 0) return;

    // Pin what the automatic layout worked out, THEN fix the layout — in that order, so nothing
    // moves. Fixing first would re-lay the table out from scratch and jump every column.
    for (const head of table.querySelectorAll("th")) {
      head.style.width = `${head.getBoundingClientRect().width}px`;
    }
    table.style.tableLayout = "fixed";

    for (const handle of handles) {
      const head = handle.closest("th");
      if (!head) continue;

      const setWidth = (px: number) => {
        head.style.width = `${Math.max(MIN_WIDTH, Math.round(px))}px`;
        // Read the width BACK rather than reporting the one just asked for. A table column cannot
        // always take the width it is given — its own content and the table's total set a floor —
        // and a splitter that announces a number the user cannot see is worse than one that does
        // not announce at all.
        handle.setAttribute(
          "aria-valuenow",
          String(Math.round(head.getBoundingClientRect().width)),
        );
      };
      // Focusable only now that there is something for the focus to do — see TableColumnResizer.
      handle.tabIndex = 0;
      handle.setAttribute("aria-valuemin", String(MIN_WIDTH));
      setWidth(head.getBoundingClientRect().width);

      handle.addEventListener("pointerdown", (event) => {
        // Stops the drag selecting the header's text — and stops the pointer's default focus, which
        // is why focus is moved explicitly below.
        event.preventDefault();
        handle.setPointerCapture(event.pointerId);
        handle.focus();
        const startX = event.clientX;
        const startWidth = head.getBoundingClientRect().width;

        const move = (e: PointerEvent) => setWidth(startWidth + (e.clientX - startX));
        const stop = () => {
          handle.removeEventListener("pointermove", move);
          handle.removeEventListener("pointerup", stop);
          handle.removeEventListener("pointercancel", stop);
        };
        handle.addEventListener("pointermove", move);
        handle.addEventListener("pointerup", stop);
        // A cancelled pointer (a touch turning into a scroll, a browser gesture) never fires
        // pointerup; without this the column would keep following the finger afterwards.
        handle.addEventListener("pointercancel", stop);
      });

      handle.addEventListener("keydown", (event) => {
        const step =
          event.key === "ArrowLeft" ? -KEY_STEP : event.key === "ArrowRight" ? KEY_STEP : 0;
        if (step === 0) return;
        event.preventDefault();
        setWidth(head.getBoundingClientRect().width + step);
      });

      // The handle sits inside the header; without this, resizing also sorts the column.
      handle.addEventListener("click", (event) => event.stopPropagation());
    }
  }

  function wire(table: HTMLElement) {
    if (!(table instanceof HTMLTableElement)) return;
    const status = table.parentElement?.querySelector<HTMLElement>(
      '[data-slot="data-table-status"]',
    );
    const announce = (text: string) => {
      if (!status) return;
      // Re-assigning the same string does not re-announce; emptying first makes a repeat audible.
      status.textContent = "";
      status.textContent = text;
    };

    wireSort(table, announce);
    wireSelection(table, announce);
    wireResizers(table);
  }

  onReadyOnce('[data-slot="data-table"]', wire);
</script>
src/components/ui/display/data-table/DataTableHead.astro
---
// src/components/ui/display/data-table/DataTableHead.astro — DataTable compound part (see ../../README.md).
// A `<th>` that can report a sort state. It is a separate file from Table's own TableHead for one
// attribute — `aria-sort` — and that attribute is the entire accessible sorting contract:
//
//   • It lives on the HEADER CELL, not on the button inside it. Put it on the button and screen
//     readers announce nothing when the user arrives at the column, which is when they need it.
//   • Exactly one header in a table may be anything but `none`. The controller enforces that by
//     clearing every other `aria-sort` on each sort, which is only possible because the unsorted
//     sortable columns render `aria-sort="none"` rather than leaving the attribute off. A column with
//     no attribute at all is not "unsorted", it is "not sortable", and the two must stay distinct.
//
// `sort` is the SERVER's answer: set it when the page arrives already sorted, so the header agrees
// with the rows before any script runs. Leave `sortable` off entirely for a column that cannot be
// sorted — an actions column, a checkbox column — and it renders a plain `<th>`.
import type { HTMLAttributes } from "astro/types";

// `align` is omitted from the native attributes on purpose: `<th align>` is a deprecated HTML
// presentation attribute typed as a narrow union, and keeping it would make our own `align` prop
// impossible to give a value the old attribute never had. The alignment belongs in `data-align`.
type Props = Omit<HTMLAttributes<"th">, "align"> & {
  /** Renders `aria-sort`, making this a sortable column. */
  sortable?: boolean;
  /** The starting sort state — for a table the server has already ordered. */
  sort?: "none" | "ascending" | "descending";
  align?: "start" | "center" | "end";
};

const {
  sortable = false,
  sort = "none",
  align = "start",
  scope = "col",
  class: className,
  ...rest
} = Astro.props;
---

<th
  scope={scope}
  aria-sort={sortable || sort !== "none" ? sort : undefined}
  class={className}
  data-slot="data-table-head"
  data-align={align}
  {...rest}
>
  <slot />
</th>
src/components/ui/display/data-table/TableColumnResizer.astro
---
// src/components/ui/display/data-table/TableColumnResizer.astro — DataTable compound part (see ../../README.md).
// The drag handle on a column's edge. Render it inside the DataTableHead whose width it changes.
//
// IT IS OPERABLE FROM THE KEYBOARD, which is the only reason it is in a headless library at all.
// A resizer that only responds to a drag is a control a keyboard user cannot use and a screen reader
// cannot find — and "resize the column to read the truncated text" is exactly the case where that
// matters. So it is a focusable `role="separator"`: arrow keys move it, and it reports its width
// through `aria-valuenow` / `aria-valuemin` the way a window splitter does.
//
// `role="separator"` is the correct role and takes a `tabindex` precisely because a FOCUSABLE
// separator is a widget (a splitter), while a static one is only a horizontal rule.
//
// The `tabindex` is added by the CONTROLLER, not written here, and that is the honest version: with
// no JavaScript there is no resizing, so a tab stop that does nothing would be worse than no control
// at all. The same goes for `aria-valuenow` — a splitter reports a width it can only know once it has
// been measured.
//
// ponytail: the arrow keys are LTR — ArrowRight widens. In a right-to-left table they are the wrong
// way round; the fix is to read `direction` off the computed style in the controller, and it is not
// written because nothing in the catalogue is RTL yet.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span"> & { label?: string };

const { label = "Resize column", class: className, ...rest } = Astro.props;
---

<span
  role="separator"
  aria-orientation="vertical"
  aria-label={label}
  class={className}
  data-slot="table-column-resizer"
  {...rest}></span>
src/components/ui/display/data-table/TablePagination.astro
---
// src/components/ui/display/data-table/TablePagination.astro — DataTable compound part (see ../../README.md).
// The strip under a table: "11–20 of 42" and the controls to move between pages.
//
// The RANGE is what earns this a file. The links themselves are already a primitive — put a
// Pagination (or a PaginationPrevNext pair, for a cursor API) in the slot — but the sentence beside
// them is arithmetic with four ways to be wrong, and every one of them ships as visible nonsense:
// "1–10 of 4", "41–50 of 42" on a partial last page, "1–10 of 0" for an empty table, or a page
// number out of a query string that nobody clamped. `page-range.ts` owns those rules and is checked.
//
// It is announced (`role="status"`), because paging usually replaces the rows without moving focus:
// without this the table silently becomes a different table. On first render there is no change to
// announce, so the region costs nothing until the page actually turns.
//
//   <TablePagination page={2} pageSize={10} total={42}>
//     <Pagination>…</Pagination>
//   </TablePagination>
import type { HTMLAttributes } from "astro/types";

import { pageRange } from "./page-range";

type Props = HTMLAttributes<"nav"> & {
  /** 1-based. Clamped into range, so a page number straight out of a query string is safe. */
  page?: number;
  pageSize?: number;
  total: number;
  /** Names the region — it is a <nav>, and an unnamed one is announced as "navigation". */
  label?: string;
  /** The word between the range and the total. Yours to translate. */
  ofLabel?: string;
};

const {
  page = 1,
  pageSize = 10,
  total,
  label = "Table pagination",
  ofLabel = "of",
  class: className,
  ...rest
} = Astro.props;

const range = pageRange(page, pageSize, total);
// Built here rather than interpolated into the markup: Astro 7 strips JSX-style whitespace between
// expressions, and "11-20of42" is the shape that bug takes.
const rangeText = `${range.from}–${range.to} ${ofLabel} ${total}`;
---

<nav
  aria-label={label}
  class={className}
  data-slot="table-pagination"
  data-page={range.page}
  data-pages={range.pages}
  {...rest}
>
  <p role="status" data-slot="table-pagination-status">{rangeText}</p>
  <slot />
</nav>
src/components/ui/display/data-table/TableRowSelect.astro
---
// src/components/ui/display/data-table/TableRowSelect.astro — DataTable compound part (see ../../README.md).
// One row's selection checkbox.
//
// THE NAME IS THE HARD PART. Fifty checkboxes all called "Select row" are fifty identical entries in
// a screen reader's controls list, with no way to tell which row any of them belongs to — the same
// failure as a page of "Read more" links. So: pass `label` and it is used verbatim
// (`label={`Select ${user.name}`}`), and if you don't, the controller derives one from the row's own
// first cell — "Select row Ada Lovelace".
//
// The default is rendered server-side rather than left to the script, so the control is never
// unnamed with JavaScript off. The script only ever UPGRADES a name it recognises as generated.
//
// A checked row needs no attribute of ours for a theme to find it — `tr:has([data-slot="table-row-
// select"]:checked)` is native CSS, it is always accurate, and it is why no JavaScript here writes
// a state onto the row.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & { label?: string };

const { label, class: className, ...rest } = Astro.props;
---

<input
  type="checkbox"
  aria-label={label ?? "Select row"}
  class={className}
  data-slot="table-row-select"
  data-auto-label={label ? undefined : "true"}
  {...rest}
/>
src/components/ui/display/data-table/TableSelectAll.astro
---
// src/components/ui/display/data-table/TableSelectAll.astro — DataTable compound part (see ../../README.md).
// The header checkbox that selects every row — and the library's THIRD indeterminate case, after
// CheckboxGroup's parent box and nothing else. It reuses the very same rule: `groupState()` in
// `checkbox-group/group-state.ts`, checked there, imported by DataTable's controller rather than
// re-derived here.
//
// `indeterminate` is a DOM PROPERTY with no HTML attribute, which is the whole reason a checkbox
// that means "some of these" cannot be written in markup at all. It renders unchecked and the
// controller promotes it to mixed once it can count the rows.
//
// It is a NATIVE checkbox rather than the library's Checkbox primitive: inside a dense table the
// browser's own control is already accessible, already sized to the row, and needs no CSS to be
// visible — which is the point of a headless library. A theme that has replaced checkboxes
// everywhere can style `[data-slot="table-select-all"]`, or you can put a `<Checkbox>` in the cell
// yourself and pass `data-slot="table-select-all"` to it; the controller looks for the attribute,
// not for the element.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & { label?: string };

const { label = "Select all rows", class: className, ...rest } = Astro.props;
---

<input
  type="checkbox"
  aria-label={label}
  class={className}
  data-slot="table-select-all"
  {...rest}
/>
src/components/ui/display/data-table/TableSortButton.astro
---
// src/components/ui/display/data-table/TableSortButton.astro — DataTable compound part (see ../../README.md).
// The control inside a sortable header. A real <button>, because sorting is an action: it has to be
// tabbable, activate on Enter AND Space, and be announced as a button rather than as a cell that
// happens to respond to clicks.
//
// It ships NO arrow glyph, deliberately. The direction is already in the header's `aria-sort`, so
// the indicator is one theme rule and no markup —
//
//   [data-slot="data-table-head"][aria-sort="ascending"] [data-slot="table-sort-button"]::after { … }
//
// — which is cheaper than shipping two SVGs and a CSS rule to choose between them, and leaves the
// arrow's shape to the theme that has an opinion about it.
//
// The button's text is the column's name and is what the sort is announced as, so keep it the column
// name and nothing else — "Name", not "Sort by name" (the button role already says it is a button,
// and the announcement says what happened).
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button">;

const { class: className, ...rest } = Astro.props;
---

<button type="button" class={className} data-slot="table-sort-button" {...rest}><slot /></button>
src/components/ui/display/data-table/TableToolbar.astro
---
// src/components/ui/display/data-table/TableToolbar.astro — DataTable compound part (see ../../README.md).
// The strip above a table: a search field, filter chips, a column picker, a bulk-action button.
// Pure structure — it exists so that every table in an app agrees on where those controls live, and
// so a theme has one selector for the row rather than a class per page.
//
// IT IS NOT `role="toolbar"`, and that is deliberate rather than an oversight. A real ARIA toolbar
// promises a specific keyboard contract: one tab stop for the whole strip, and arrow keys to move
// between the controls inside it. Claiming the role without implementing the roving focus makes the
// control WORSE than a plain group — a screen reader announces a toolbar and the arrow keys then do
// nothing. Tabbing between a handful of controls is fine; the role is for dense strips of twenty.
//
// With `label` it renders `role="group"`, which names the set and promises nothing it does not do.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** Names the strip — "Invoice filters". Renders `role="group"`. */
  label?: string;
  align?: "start" | "between" | "end";
};

const { label, align = "between", class: className, ...rest } = Astro.props;
---

<div
  role={label ? "group" : undefined}
  aria-label={label}
  class={className}
  data-slot="table-toolbar"
  data-align={align}
  {...rest}
>
  <slot />
</div>
src/components/ui/display/data-table/index.ts
import DataTable from "./DataTable.astro";
import DataTableHead from "./DataTableHead.astro";
import TableColumnResizer from "./TableColumnResizer.astro";
import TablePagination from "./TablePagination.astro";
import TableRowSelect from "./TableRowSelect.astro";
import TableSelectAll from "./TableSelectAll.astro";
import TableSortButton from "./TableSortButton.astro";
import TableToolbar from "./TableToolbar.astro";

export { type PageRange, pageRange } from "./page-range";
export { compareValues, nextDirection, orderBy, type SortDirection } from "./sort";
export {
  DataTable,
  DataTableHead,
  TableColumnResizer,
  TablePagination,
  TableRowSelect,
  TableSelectAll,
  TableSortButton,
  TableToolbar,
};
export default DataTable;
src/components/ui/display/data-table/page-range.ts
// src/components/ui/display/data-table/page-range.ts — the arithmetic behind TablePagination's "1–10 of 42",
// in a plain module so it is unit-checkable (see page-range.test.ts).
//
// It is three lines of maths with four ways to be wrong, all of which reach the user as a sentence
// that is visibly nonsense: "1–10 of 4", "41–50 of 42", "1–0 of 0", or a last page that claims ten
// rows and shows two.

/** A page's place in a result set, ready to render. */
export interface PageRange {
  /** 1-based index of the first row on this page; 0 when there are no rows at all. */
  from: number;
  /** 1-based index of the last row on this page — never past `total`. */
  to: number;
  /** The page actually described, clamped into range. */
  page: number;
  /** How many pages there are; 0 for an empty set. */
  pages: number;
}

/**
 * Work out which rows a page covers.
 *
 * Everything hostile is clamped rather than trusted, because these numbers usually arrive from a
 * query string: a page past the end reports the last page, a page below 1 reports the first, and an
 * empty result set reports 0–0 of 0 rather than "1–10 of 0".
 *
 * @param page - the 1-based page number
 * @param pageSize - rows per page
 * @param total - rows in the whole result set
 * @example pageRange(5, 10, 42) // => { from: 41, to: 42, page: 5, pages: 5 }
 */
export function pageRange(page: number, pageSize: number, total: number): PageRange {
  const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 0;
  const rows = Number.isFinite(total) && total > 0 ? Math.floor(total) : 0;
  const pages = size > 0 ? Math.ceil(rows / size) : 0;
  const current = Math.min(Math.max(Number.isFinite(page) ? Math.floor(page) : 1, 1), pages || 1);
  if (pages === 0) return { from: 0, to: 0, page: 1, pages: 0 };
  return {
    from: (current - 1) * size + 1,
    to: Math.min(current * size, rows),
    page: current,
    pages,
  };
}
src/components/ui/display/data-table/sort.ts
// src/components/ui/display/data-table/sort.ts — the comparison rules behind DataTable's column sort, in a
// plain module so they are unit-checkable (see sort.test.ts) without a table, a DOM, or a click.
//
// The DOM half of sorting is four lines — read a column's cells, ask this module for an order, append
// the rows in it. Everything that can actually be wrong lives here.

/** The two values `aria-sort` takes while a column is sorted. (`none` is the unsorted state.) */
export type SortDirection = "ascending" | "descending";

/**
 * What the next click on a column header should sort by.
 *
 * Ascending first, from every state but ascending itself: an unsorted column and a descending one
 * both go up, which is what makes a second click on a DIFFERENT column behave predictably rather
 * than inheriting the last column's direction.
 *
 * @param current - the header's current `aria-sort`
 * @example nextDirection("ascending") // => "descending"
 */
export function nextDirection(current: string | null | undefined): SortDirection {
  return current === "ascending" ? "descending" : "ascending";
}

/**
 * Compare two cell values for sorting.
 *
 * Numbers are compared as numbers, which is the whole reason this is not a bare `localeCompare`:
 * as text, 10 sorts before 9 and 1,000 before 200. The numeric branch is guarded on both values
 * being finite AND non-blank, because `Number("")` is 0 — without that guard an empty cell sorts as
 * a zero, which puts blanks in the middle of a numeric column.
 *
 * Everything else is a locale comparison with `numeric` collation, so "Item 2" still precedes
 * "Item 10", and with base sensitivity, so "apple" and "Apple" tie rather than splitting the list by
 * capitalisation.
 *
 * @param a - the first cell's sort value
 * @param b - the second cell's sort value
 * @returns negative, zero or positive, as a comparator
 */
export function compareValues(a: string, b: string): number {
  const [x, y] = [Number(a), Number(b)];
  if (a.trim() !== "" && b.trim() !== "" && Number.isFinite(x) && Number.isFinite(y)) return x - y;
  return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}

/**
 * The order to re-append rows in: the indexes of `values`, sorted.
 *
 * Returning indexes rather than sorted values is what keeps the caller honest — a row is not its
 * cell's text, and sorting the strings would leave you having to find each row again.
 *
 * The sort is STABLE (guaranteed by the language since ES2019), which matters more than it looks:
 * rows that tie in the sorted column keep the order they had, so sorting by Status and then by Name
 * gives a list grouped by status within name, and re-sorting the same column never reshuffles ties.
 *
 * @param values - each row's value in the sorted column, in DOM order
 * @param direction - which way to sort
 * @returns indexes into `values`, in their new order
 * @example orderBy(["b", "a"], "ascending") // => [1, 0]
 */
export function orderBy(values: readonly string[], direction: SortDirection): number[] {
  const sign = direction === "descending" ? -1 : 1;
  return values.map((_, i) => i).sort((i, j) => sign * compareValues(values[i], values[j]));
}

What you get

The component source, copied into your project by npx astrocraft-ui add display/data-table — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.