Nasaq
Components

Data Table

The interactive layer on top of Table. A useDataTable hook plus opt-in pieces for sorting (multi-column), search, facet and range filters, column visibility, pinning and resizing, expandable rows, selection with bulk actions, row actions, keyboard navigation, pagination with page sizes, and loading, empty and error states.

PreviewOpen ↗

Code

const columns: DataTableColumn<Issue>[] = [  { id: "key", header: "Key", cell: (r) => r.key, sortValue: (r) => r.key, searchValue: (r) => r.key, hideable: false },  { id: "title", header: "Title", cell: (r) => r.title, sortValue: (r) => r.title, searchValue: (r) => r.title },  { id: "status", header: "Status", cell: (r) => <Status tone={tone(r)}>{r.status}</Status>, filterValue: (r) => r.status },  { id: "due", header: "Due", cell: (r) => format(r.due), sortValue: (r) => r.due, align: "end" },];function Issues({ issues }: { issues: Issue[] }) {  const table = useDataTable({ data: issues, columns, getRowId: (r) => r.key, pageSize: 20, selectable: true });  return (    <div className="flex flex-col gap-3">      <DataTableToolbar>        <DataTableSearch table={table} placeholder="Search issues…" />        <DataTableFacetFilter table={table} column="status" options={statusOptions} />        <DataTableViewOptions table={table} />      </DataTableToolbar>      <DataTable        table={table}        label="Issues"        onRowClick={(r) => navigate(`/issues/${r.key}`)}        rowActions={(r) => [          { id: "edit", label: "Edit", icon: Pencil, onSelect: () => edit(r) },          { id: "delete", label: "Delete", icon: Trash2, danger: true, group: "danger", onSelect: () => remove(r) },        ]}      />      <DataTablePagination table={table} />    </div>  );}

Data Display · beta

Live examples and controls: Data Table in the lab.

Install

npx shadcn@latest add https://docs.nasaqui.com/r/data-table.json

Table plus behaviour. It looks exactly like Table: same rows, borders, hover and selected tint. useDataTable holds the state (sort, search, filters, page, selection, hidden columns) and returns a table instance. You pass that instance to whichever pieces you need.

Every capability is opt-in. A table with only sortable columns has no toolbar, no checkboxes and no pager.

You wantAdd
SortingsortValue on a column
SearchsearchValue on columns + <DataTableSearch>
Filter by valuefilterValue on a column + <DataTableFacetFilter>
Show/hide columns<DataTableViewOptions> (hideable: false to pin a column)
Selectionselectable: true (+ <DataTableBulkActions> for actions on the selection)
Row menurowActions on <DataTable>
Open a rowonRowClick on <DataTable>
PagingpageSize + <DataTablePagination>
Statesloading, error + onRetry, empty on <DataTable>

When to use

  • Lists that people work through: issues, invoices, members, apps, time entries.
  • Any table that needs to sort, filter, select or page.

When not to use

  • Read-only rows with nothing to do: use Table directly. It is lighter.
  • Spreadsheet editing (cell focus, inline editing): out of scope.
  • Very large lists (thousands of rows client-side): paginate on the server with manual.

Import

import {
  DataTable, DataTableFacetFilter, DataTablePagination, DataTableSearch,
  DataTableToolbar, DataTableViewOptions, useDataTable, type DataTableColumn,
} from "@fadymondy/nasaq/web";

Quick start

const columns: DataTableColumn<Issue>[] = [
  { id: "key", header: "Key", cell: (r) => r.key, sortValue: (r) => r.key, searchValue: (r) => r.key, hideable: false },
  { id: "title", header: "Title", cell: (r) => r.title, sortValue: (r) => r.title, searchValue: (r) => r.title },
  { id: "status", header: "Status", cell: (r) => <Status tone={tone(r)}>{r.status}</Status>, filterValue: (r) => r.status },
  { id: "due", header: "Due", cell: (r) => format(r.due), sortValue: (r) => r.due, align: "end" },
];

function Issues({ issues }: { issues: Issue[] }) {
  const table = useDataTable({ data: issues, columns, getRowId: (r) => r.key, pageSize: 20, selectable: true });
  return (
    <div className="flex flex-col gap-3">
      <DataTableToolbar>
        <DataTableSearch table={table} placeholder="Search issues…" />
        <DataTableFacetFilter table={table} column="status" options={statusOptions} />
        <DataTableViewOptions table={table} />
      </DataTableToolbar>
      <DataTable
        table={table}
        label="Issues"
        onRowClick={(r) => navigate(`/issues/${r.key}`)}
        rowActions={(r) => [
          { id: "edit", label: "Edit", icon: Pencil, onSelect: () => edit(r) },
          { id: "delete", label: "Delete", icon: Trash2, danger: true, group: "danger", onSelect: () => remove(r) },
        ]}
      />
      <DataTablePagination table={table} />
    </div>
  );
}

Define columns outside the component, or wrap it in useMemo.

Anatomy

DataTableToolbar            search · facet filters · View (pushed to the inline end)
  or DataTableBulkActions   "3 selected" · your buttons · ✕ (swap it in while table.selection.size > 0)
DataTable                   ☐ | sortable headers ↕ | … | ⋯ row actions
DataTablePagination         "1–20 of 143"  ‹ ›

API

useDataTable(options)

OptionTypeNotes
data, columns, getRowIdRequired.
pageSizenumberOmit for no pagination.
selectablebooleanAdds the checkbox column.
density, frame, bordered, striped, hoversee TableTable style, passed straight to Table. Default: density="default", hover on.
defaultSortDataTableSort | null{ id, direction }.
sort, query, filters, page, selection, hidden{ value, onChange }Control any piece of state (URL params, server).
manual, rowCountboolean, numberdata is already sorted, filtered and paged by the server.
multiSortbooleanShift-click a header to add it as a further sort key.
defaultSorting, sortingDataTableSort[]All sort keys, in priority order. sorting is { value, onChange }. sort still works and controls the first key.
ranges{ value, onChange }Control the range filters: Record<columnId, { min?, max? }>.
perPage{ value, onChange }Control the page size (the rows-per-page choice).
expanded{ value, onChange }Control which rows are open (Set of row ids).
pinning{ value, onChange }Control pinning: { start?: ids, end?: ids }. Uncontrolled, columns start from their pin.
resizable, sizesboolean, { value, onChange }Drag, or arrow-key, column borders. sizes is Record<columnId, px>.

It returns a DataTableInstance: rows (what to render), rowCount, sort/toggleSort, query/setQuery, filters/setFilter/resetFilters, isFiltered, page/setPage/pageCount, hidden/toggleColumn, selection/selectedRows/toggleRow/togglePage/setSelection, plus sorting/setSorting, ranges/setRange, setPageSize, expanded/toggleExpanded/setExpanded, pinning/pinColumn/pinOf and sizes/setColumnSize. visibleColumns is already in pinned order.

Any change to sort, search or filters goes back to page 1. Search is case-insensitive and folds Arabic (أ/إ/آ → ا, ة → ه, ى → ي, no tashkeel), so "مراجعه" finds "مراجعة". Sorting uses Intl.Collator with numeric ordering, is stable, and puts empty values last.

With multiSort, a plain click sorts by that column alone (asc, desc, off). Shift-click adds it as the next key, then flips it, then removes it. Headers show the key's position (1, 2, …) when there is more than one.

The pure helpers behind this are exported too and have no React in them: nextSorting, sortTableRows, rangeBound, rangeValueOf, isActiveRange, inRange, orderByPinning, pinColumnIn, pinOffsets and clampColumnSize, with the types DataTableSort, DataTableSortDirection, DataTableRange, DataTablePinning and SortableValue. Use them to run the same sort or range logic on a server.

DataTableColumn<T>

id, header, cell (required). label (a plain-text name when header is not a string), sortValue, searchValue, filterValue, align (start · end · center), hideable (default true), defaultHidden, className, headerClassName. rangeValue (a number or date for DataTableRangeFilter), pin (start · end), size, minSize, maxSize (pixels) and resizable (default true when the table is resizable).

<DataTable>

PropNotes
table, labelRequired. label is the table's accessible name.
rowLabelA row's name for "Select MH-728" / "Actions for MH-728". Defaults to the id.
onRowClickClick, or Enter on the focused row. Ignores clicks on controls inside the row.
rowActions(row) => DataTableRowAction[]: { id, label, icon?, onSelect, danger?, disabled?, group? }.
loadingSkeleton rows and aria-busy.
error, onRetryReplaces the body with ErrorState.
emptyShown when there is no data. When filtering leaves no rows, "No matching results" with Clear filters is shown instead.
renderExpanded(row) => ReactNode. Adds an expand button column; the detail shows in a full-width row under it.
canExpand(row) => boolean. Rows that return false get no expand button.
labelsOverride any built-in string.

Pieces

  • DataTableToolbar: a flex row. Put the controls in it.
  • DataTableSearch: table, placeholder. Esc clears it.
  • DataTableFacetFilter: table, column, options: { value, label, icon? }[], title?. A dashed button when empty. Shows one chosen label, or the count.
  • DataTableRangeFilter: table, column (with rangeValue), kind (number · date), min, max, step, format, title?. From / To inputs in a popover; the button reads "100–500", "≥ 100" or "≤ 500". Both ends are inclusive; a date max covers the whole day.
  • DataTableViewOptions: the column checklist. The last visible column cannot be hidden. pinning adds Pin to start / Pin to end / Unpin per column; density + onDensityChange add a density choice.
  • DataTableBulkActions: table + your buttons. Hidden when nothing is selected. It includes the count and a clear button.
  • DataTablePagination: renders nothing when there is only one page. pageSizeOptions (e.g. [10, 25, 50]) adds a rows-per-page select; it then shows whenever there are more rows than the smallest option.

Examples

Sort only. useDataTable({ data, columns, getRowId, defaultSort: { id: "due", direction: "asc" } }) and <DataTable table={table} label="Issues" />. No other pieces are needed.

Server-side.

const [sort, setSort] = useState<DataTableSort | null>(null);
const [page, setPage] = useState(0);
const { data, total } = useIssues({ sort, page });
const table = useDataTable({
  data, columns, getRowId, pageSize: 20, manual: true, rowCount: total,
  sort: { value: sort, onChange: setSort }, page: { value: page, onChange: setPage },
});

See the lab for all states: Default, SortOnly, States, MultiSort, PinnedAndResizable, Expandable, RangeFilters, DensityAndPageSize.

Context menu, table actions and in-cell edit

Context menu. rowActions also open as a context menu at the pointer on right-click, and at the row on Shift+F10 or the Menu key. Escape returns focus to the row. Inputs, links and Shift+right-click keep the browser's own menu. Pass contextMenu={false} to opt out; without rowActions nothing changes. It is the shared ContextMenuActions from the context-menu component.

Table actions. <DataTableActions actions={[...]} /> in the toolbar: one primary button, secondary buttons, an overflow group in a ⋯ menu, iconOnly for refresh, loading for a spinner. Below the sm breakpoint the secondary buttons fold into the menu. It sits next to DataTableBulkActions, which replaces the toolbar while rows are selected.

In-cell edit. Give a column edit: { type, value, options, validate, disabled, label, render } and the table an onCellEdit(row, columnId, value). Types: text (default), number, select, date, switch (toggles in place) and custom (render gets commit/cancel/error).

KeyAction
Enter, F2, double-clickStart editing (typing a character also starts, replacing the value)
EnterSave and move down
Tab / Shift+TabSave and move to the next / previous editable cell
EscCancel and return focus to the cell

validate runs first and keeps the editor open with its message. onCellEdit may return { error } or throw: the cell shows the new value with a spinner while pending, then rolls back and shows the message. Update your data before it resolves. Editors are shared with ContentTableEditor (cell-editors.tsx). Numbers accept Arabic-Indic digits.

Accessibility

  • A real <table> with aria-label. Sorted headers set aria-sort, and each sortable header is a button.
  • Rows use a roving tabindex, so Tab enters the table once. ↑ ↓ Home End move between rows. Enter opens the row (onRowClick). Space toggles selection. Shift+F10 or the context-menu key opens the row menu.
  • The row checkbox and ⋯ button are only in the tab order on the active row.
  • The ⋯ appears on hover, on focus, on selected rows, and always on touch screens.
  • The header checkbox reports mixed when only part of the page is selected.
  • Selection count and page range are live text. Loading sets aria-busy.

RTL & i18n

  • Built-in strings in English and Arabic, chosen from the Nasaq locale. Override them with labels.
  • Columns follow the reading direction. align: "end" means the inline end, and the ⋯ sits at the inline end.
  • Pager chevrons mirror in RTL. Numbers use Latin digits (the Nasaq numeral rule).
  • Localise label, headers and rowLabel yourself.

Styling & tokens

Rows are the Table rows: hover:bg-nq-hover, and selected rows use bg-nq-selected with data-state="selected". The bulk bar uses bg-nq-selected. Toolbar controls use h-control-sm. Pass className to the table, or set className/headerClassName on a column for widths (w-24).

Do / Don't

  • Do turn on only what the screen needs. A settings list rarely needs search, and a five-row table never needs paging.
  • Do give every column a stable id. It keys sort, filter and visibility state.
  • Don't put a primary action inside rowActions only. If opening the row is the main action, use onRowClick.
  • Don't hide the column that names the row. Set hideable: false on it.

table (the primitives), checkbox, states (Empty/Error), dropdown-menu, page-actions, status.

Lab

https://docs.nasaqui.com/?path=/docs/components-data-display-data-table--docs

On this page