QdsDataTable

Tabular data component that composes qds-column-header, qds-checkbox, qds-row-actions, qds-pagination, and qds-empty-state.

Overview

Use qds-data-table for rows of records the user reads, sorts, selects, and acts on. Do not use it for layout, for a key/value detail panel (that is a description list), or for a spreadsheet-style editable grid — inline editing is a different component with a different interaction model.

Above roughly 2,000 rows in the DOM, this component will feel slow. See the 2,000-row ceiling below.

Real table in shadow DOM

The component renders a real <table> with <thead>, <tbody>, <tr>, <th>, and <td> elements — not a grid of divs. Native table layout gives correct column sizing, correct colspan, and correct screen-reader navigation for free.

Each <th> contains one qds-column-header and carries aria-sort itself (not the header component). The <th> owns aria-sort because it has role="columnheader".

Client vs server mode

sort-mode="client": the table sorts rows itself and re-renders.

sort-mode="server": the table fires qds-sort-change and renders whatever rows it is next given, in the given order.

The same split applies to pagination (pagination-mode).

Never mix modes. A table that sorts the current page client-side while the server holds the rest produces an order that is wrong in a way users cannot see.

Page-scoped select-all

Select-all applies to the current page only. A select-all that silently spans 1,284 unloaded rows followed by a delete is a data-loss incident. If a select-everything affordance is needed, it is an explicit second action in a selection toolbar, which is not in this component.

textContent-only cell rule

Never innerHTML a cell value. Table data comes from an API; a name field containing markup must render as text. The component uses textContent for all cell values, or appends a Node the consumer built themselves via the column render function.

Sticky-column width requirement

Sticky columns must declare a width. The table computes left/right offsets from preceding sticky columns’ widths; without a width the offset cannot be calculated.

Sticky-header height requirement

sticky-header needs a scrolling region to stick inside. Set the height constraint on the qds-data-table element itself — for example <qds-data-table sticky-header style="max-height: 400px"> — not on an outer wrapper <div>. The table’s internal [part="scroll"] region is a flex child of the host; a height set on a wrapper around the table doesn’t reach it, so the table just renders at full content height and nothing actually scrolls.

Do not set display on qds-data-table from page-level CSS (e.g. a rule like qds-data-table { display: block } for spacing purposes). The host’s own display: flex is load-bearing for this same reason — a page-level rule with higher specificity than the component’s internal :host rule will win the cascade and silently break the scroll region’s sizing. Use margin for spacing instead.

2,000-row ceiling

v1 renders every row. At 40px rows, 2,000 rows is roughly 12,000 cells, which is where scrolling starts to stutter on a mid-range laptop. Mitigations in scope: textContent not innerHTML, DocumentFragment for row building, reused Intl formatter instances per column. Windowing is out of scope. If a consumer needs 10k rows before windowing exists, they paginate at 100.

Usage

<qds-data-table id="positions" density="md" selectable sticky-header></qds-data-table>
<script type="module">
  const t = document.getElementById('positions');
  t.columns = [
    { key: 'symbol', label: 'Symbol', sortable: true, sticky: 'start', width: '120px' },
    { key: 'name', label: 'Name', sortable: true, minWidth: '200px' },
    { key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' },
    { key: 'quantity', label: 'Quantity', type: 'number', sortable: true },
    { key: 'marketValue', label: 'Market value', type: 'currency', sortable: true },
    { key: 'weight', label: 'Weight', type: 'percent', sortable: true }
  ];
  t.rows = await loadPositions();
  t.actions = [
    { id: 'view', label: 'View detail', icon: 'lucide:external-link' },
    { id: 'delete', label: 'Remove position', destructive: true }
  ];
</script>

API Reference

Attributes

Attribute Type Default Notes
density sm | md | lg md Row density: 32/40/48px row height
striped boolean false Zebra striping
sticky-header boolean false Sticky table header
selectable boolean false Leading checkbox column
selection-mode multiple | single multiple single renders radio-style behaviour with checkboxes
selected-ids string — Comma-separated selected row ids
sort-mode client | server client See client vs server mode
paginate boolean false Renders qds-pagination in the footer
page number 1 Current page, 1-based
page-size number 25 Rows per page
total number rows.length Total row count for pagination
pagination-mode client | server client See client vs server mode
row-id-key string id Which field identifies a row
row-label-key string first text column Used for accessible names in row actions and checkboxes
empty-title string — Title for the default empty state
empty-description string — Description for the default empty state
loading boolean false Renders skeleton rows, not empty state
caption string — Visually hidden <caption>

Properties

Property Type Default Notes
columns QdsColumnDef[] [] Column definitions. Property only — carries functions
rows QdsTableRow[] [] Row data. Property only
actions QdsRowAction[] [] Row action definitions. Property only
sort QdsSortState[] [] Sort state, multi-column in priority order
selectedIds string[] [] Selected row ids

Column definitions

columns is an array of QdsColumnDef objects. key identifies the row-data field; label supplies the column header. Use type for standard formatting, or render() only when the cell needs custom DOM.

Field Type Notes
key string Required row-data field. For type: 'link', its value is the link URL.
label string Required header text.
type text | number | currency | percent | date | link | custom Defaults to text.
textKey string Link-only. Row field used for link text; defaults to key.
target _self | _blank | _parent | _top Link-only. Defaults to _self; _blank automatically adds rel="noopener noreferrer".
format function Returns plain text.
render function Returns a DOM Node or plain text for custom cells.

Use type: 'link' for ordinary row links. The URL comes from key, and the optional textKey supplies the visible label. The table creates the anchor, applies QDS link and focus styling, and rejects unsafe URL schemes. Do not put HTML in row data: it is rendered as text.

{ key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' }

CSS Parts

Part Element
container Outer wrapper
toolbar Toolbar slot wrapper
scroll Scroll container
table The table element
thead Table header
header-row Header row
header-cell Header cell (th)
tbody Table body
row Body row
row-selected Added to selected rows
row-focused Added to focused row
cell Body cell
cell-numeric Added to numeric cells
select-cell Selection checkbox cell
actions-cell Row actions cell
footer Footer wrapper
pagination Pagination component
empty Empty state row
skeleton-row Skeleton loading row

CSS Custom Properties

Property Default
--qds-row-height var(--qds-control-height-md, 40px)
--qds-row-font-size 14px
--qds-row-padding var(--theme-space-3, 12px)

Events

Event When Detail
qds-sort-change Sort changed { component, sort: QdsSortState[] }
qds-selection-change Selection changed { component, selectedIds, row? }
qds-row-click Row body clicked or Enter on focused row { component, rowId, row }
qds-row-action Relayed from qds-row-actions with row populated { component, action, rowId, row }
qds-page-change Relayed from qds-pagination { component, page, pageSize, total }
qds-column-resize Column resize committed { component, key, width }

Slots

Slot Purpose
empty Replaces the default empty state
toolbar Above the header — filters, search, bulk actions
footer Below pagination

Example

<qds-data-table
  id="positions"
  density="md"
  selectable
  striped
  paginate
  page-size="10"
  caption="Portfolio positions"
  empty-title="No positions yet"
  empty-description="Positions appear here once your portfolio is loaded."
>
  <div slot="toolbar">
    <input type="search" placeholder="Filter positions..." />
  </div>
</qds-data-table>

<script type="module">
  const table = document.getElementById('positions');
  table.columns = [
    { key: 'symbol', label: 'Symbol', sortable: true, sticky: 'start', width: '120px' },
    { key: 'name', label: 'Name', sortable: true, minWidth: '200px' },
    { key: 'website', label: 'Website', type: 'link', textKey: 'name', target: '_blank' },
    { key: 'quantity', label: 'Quantity', type: 'number', sortable: true },
    { key: 'marketValue', label: 'Market value', type: 'currency', sortable: true },
    { key: 'weight', label: 'Weight', type: 'percent', sortable: true }
  ];
  table.rows = [
    { id: '1', symbol: 'AAPL', name: 'Apple Inc.', website: 'https://www.apple.com/', quantity: 100, marketValue: 18500, weight: 0.185 },
    { id: '2', symbol: 'MSFT', name: 'Microsoft Corp.', website: 'https://www.microsoft.com/', quantity: 50, marketValue: 21250, weight: 0.213 },
    { id: '3', symbol: 'GOOGL', name: 'Alphabet Inc.', website: 'https://abc.xyz/', quantity: 30, marketValue: 4200, weight: 0.042 }
  ];
  table.actions = [
    { id: 'view', label: 'View detail', icon: 'pi-external-link' },
    { id: 'delete', label: 'Remove position', destructive: true }
  ];

  table.addEventListener('qds-selection-change', (e) => {
    console.log('Selected:', e.detail.selectedIds);
  });
  table.addEventListener('qds-row-click', (e) => {
    console.log('Row clicked:', e.detail.rowId, e.detail.row);
  });
</script>