TableATM
A responsive table component for displaying tabular data. Built on native <table> with styled headers, rows, and cells.
On this page (17)
§Basic Table
Standard table with header and body rows. Uses .badge for status and .avatar for user display.
Table with a summary footer row. The invoice numbers are row headers: th class="table-cell
§Empty state
A table with no rows keeps its header and shows one .table-empty cell spanning every column (colspan) - an icon, a line of text, an optional action - instead of an empty box.
§Sortable columns
A .table-sort button in a header makes the column sortable: click for ascending, again for descending, a third time for the authored order. aria-sort on the th draws the arrow and tells screen readers. Text sorts with Intl.Collator (numeric: item 2 before item 10); data-sort-value gives formatted cells a raw key (amounts, dates).
§Selectable rows
A leading .table-select column of checkboxes; the one in the header selects all - it shows the dash when only some rows are chosen. Shift+click selects a range. Selected rows carry aria-selected and a tint; every change fires table-select.
§Reorderable rows
A .table-handle grip column: drag a row by its grip - a line shows where it lands. From the keyboard, Alt+ArrowUp / Alt+ArrowDown moves the focused row. Every move fires table-reorder.
§Alignment
data-align on a th / td - start, center, end (logical: they flip in RTL); data-numeric right-aligns tabular figures so digits line up; data-valign='top' pins a cell to the top of a tall row.
§Locked columns and header
data-lock-start='2' keeps the checkbox and the region in view while the container scrolls sideways; data-lock-end='1' keeps the Actions dropdown (a .table-menu popover per row); data-sticky-header keeps the header while it scrolls down (the container has a max-height). Scroll both ways.
§Action column
A .table-actions cell holds the row's buttons, end-aligned and always visible. Mobile: on a narrow table (under 40rem - switch the device toolbar to Phone) the inline buttons (.table-actions-inline) give way to one ⋯ button (.table-actions-menu) opening a native popover menu (.table-menu) - Escape or a pick closes it. On touch screens every action is a 44px target.
§Quiet actions
data-actions='quiet' dims the action buttons until the row is hovered or focused - still visible and clickable; touch screens always show them at full strength.
§Text in cells
Ways to render text: a .table-sub second line, a .table-truncate single line with an ellipsis (full text in title), a .table-clamp of two lines, .table-mono for ids and code, .table-muted, plus any component - badges, avatars, links, progress.
§Data grid
Everything together: select, sort, reorder and act - a sortable column resets once you drag a row into a manual order.
§Right to left
Alignment, locked columns and the sort arrow are logical - in dir='rtl' start is the right edge.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, bound on every table.table:
default- as authored: the original row order, no sort, nothing selectedsorted- byconfig.column(index) andconfig.direction(ascending / descending)selected-config.rows: row indices or'all'
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/table-{state}.png.
Machine contract - verified against table.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
sorted | boolean | true, false | false | Rows sorted by a column (setState('sorted', { column, direction })). |
selected | boolean | true, false | false | Rows selected (setState('selected', { rows })). |
§CSS view file
Styles for the table component. Uses design tokens for colors, spacing, and radius.
@layer components { .table-container { width: 100%; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-xl); container-type: inline-size; } .table { width: 100%; border-collapse: collapse; font-size: 0.875rem; caption-side: bottom; } .table-caption { padding: 0.75rem 1rem; font-size: 0.8125rem; color: var(--muted-foreground); text-align: center; } .table-head { padding: 0.75rem 1rem; /* explicit: a th's UA default centers unless the table sets a non-initial alignment */ text-align: start; font-weight: 500; color: var(--muted-foreground); background-color: var(--muted); white-space: nowrap; border-bottom: 1px solid var(--border); &:first-child { border-top-left-radius: var(--radius-lg); } &:last-child { border-top-right-radius: var(--radius-lg); } } .table-row { border-bottom: 1px solid var(--border); transition: background-color 150ms ease; &:last-child { border-bottom: none; } tbody &:hover { background-color: var(--muted); } } .table-cell { padding: 0.75rem 1rem; vertical-align: middle; color: var(--foreground); /* a row header (<th scope="row">) reads like a cell: start-aligned, not the UA's centered th */ &:is(th) { text-align: start; font-weight: 500; } } tfoot .table-row { background-color: var(--muted); border-top: 1px solid var(--border); font-weight: 500; } tfoot .table-cell:first-child { border-bottom-left-radius: var(--radius-lg); } tfoot .table-cell:last-child { border-bottom-right-radius: var(--radius-lg); } /* -- Density ---------------------------------------------------- data-density on the .table root scales cell/head/caption padding. Scale matches sizing.css (0.75 / 1 / 1.25): comfortable == the unsized default (0.75rem 1rem), compact 0.375rem 0.625rem, spacious 1rem 1.25rem. :where() gives these (0,2,0) - an explicit density always beats the automatic narrow-container compaction below. */ .table:where([data-density="compact"]) { & .table-head, & .table-cell { padding: 0.375rem 0.625rem; } & .table-caption { padding: 0.5rem 0.625rem; } } .table:where([data-density="comfortable"]) { & .table-head, & .table-cell { padding: 0.75rem 1rem; } & .table-caption { padding: 0.75rem 1rem; } } .table:where([data-density="spacious"]) { & .table-head, & .table-cell { padding: 1rem 1.25rem; } & .table-caption { padding: 1rem 1.25rem; } } /* -- Alignment: data-align on a th / td (start / center / end); data-numeric: end-aligned tabular figures (amounts, counts) ---------- */ :is(.table-head, .table-cell) { &[data-align="start"] { text-align: start; } &[data-align="center"] { text-align: center; } &[data-align="end"] { text-align: end; } &[data-numeric] { text-align: end; font-variant-numeric: tabular-nums; } &[data-valign="top"] { vertical-align: top; } &[data-nowrap] { white-space: nowrap; } } .table { text-align: start; } /* -- Sorting: a .table-sort button in the header; aria-sort on the th (ascending / descending) draws the arrow, table.js sorts the rows ---- */ .table-sort { display: inline-flex; align-items: center; gap: 0.375rem; margin: -0.25rem -0.5rem; padding: 0.25rem 0.5rem; border: 0; border-radius: var(--radius-sm); background: none; color: inherit; font: inherit; cursor: pointer; &:hover { color: var(--foreground); background-color: color-mix(in oklch, var(--foreground) 6%, transparent); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } /* the arrow: both halves muted while unsorted, one strong when sorted */ &::after { content: ''; width: 0.5rem; height: 0.75rem; flex-shrink: 0; background: conic-gradient(from 150deg at 50% 0, currentColor 60deg, transparent 0) top / 100% 40% no-repeat, conic-gradient(from -30deg at 50% 100%, currentColor 60deg, transparent 0) bottom / 100% 40% no-repeat; opacity: 0.35; } [data-align="end"] > &, [data-numeric] > & { flex-direction: row-reverse; } } .table-head[aria-sort="ascending"] .table-sort::after { opacity: 1; background: conic-gradient(from 150deg at 50% 0, currentColor 60deg, transparent 0) center / 100% 50% no-repeat; } .table-head[aria-sort="descending"] .table-sort::after { opacity: 1; background: conic-gradient(from -30deg at 50% 100%, currentColor 60deg, transparent 0) center / 100% 50% no-repeat; } .table-head[aria-sort]:not([aria-sort="none"]) { color: var(--foreground); } /* -- Selection: a leading .table-select cell with a checkbox (the header one selects all); a selected row is tinted -------------------------- */ .table-select { width: 1%; padding-inline-end: 0 !important; & .checkbox { vertical-align: middle; } } .table-row[aria-selected="true"] { background-color: color-mix(in oklch, var(--primary) 7%, transparent); tbody &:hover { background-color: color-mix(in oklch, var(--primary) 11%, transparent); } } /* -- Reordering: a .table-handle cell with a grip button; the row being dragged fades, a line shows where it lands -------------------------- */ .table-handle { width: 1%; padding-inline-end: 0 !important; color: var(--muted-foreground); & button { display: inline-grid; place-items: center; width: 1.5rem; height: 1.5rem; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: inherit; cursor: grab; &:hover { color: var(--foreground); background-color: var(--accent); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; } & svg { width: 1rem; height: 1rem; } } } .table-row[data-dragging] { opacity: 0.4; } .table-row[data-drop="before"] { box-shadow: inset 0 2px 0 var(--primary); } .table-row[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--primary); } /* -- Locked columns + sticky header -------------------------------------- data-lock-start="1|2|3" on the .table keeps the first columns in view while the .table-container scrolls sideways (table.js measures their offsets into --table-lock-1 / -2); data-lock-end="1" the last column. data-sticky-header keeps the header row while the container scrolls vertically (give the container a max-height). */ .table[data-lock-start] :is(.table-head, .table-cell), .table[data-lock-end] :is(.table-head, .table-cell) { background-clip: padding-box; } .table[data-lock-start] :is(tr > :nth-child(1)), .table:is([data-lock-start="2"], [data-lock-start="3"]) :is(tr > :nth-child(2)), .table[data-lock-start="3"] :is(tr > :nth-child(3)), .table[data-lock-end] :is(tr > :last-child) { position: sticky; z-index: 1; background-color: var(--_lock-bg, var(--background)); } .table[data-lock-start] tr > :nth-child(1) { inset-inline-start: 0; } .table:is([data-lock-start="2"], [data-lock-start="3"]) tr > :nth-child(2) { inset-inline-start: var(--table-lock-1, 0px); } .table[data-lock-start="3"] tr > :nth-child(3) { inset-inline-start: calc(var(--table-lock-1, 0px) + var(--table-lock-2, 0px)); } .table[data-lock-end] tr > :last-child { inset-inline-end: 0; } .table thead .table-head { --_lock-bg: var(--muted); } .table tfoot .table-cell { --_lock-bg: var(--muted); } /* the edge of the locked block casts a hairline */ .table[data-lock-start="1"] tr > :nth-child(1), .table[data-lock-start="2"] tr > :nth-child(2), .table[data-lock-start="3"] tr > :nth-child(3) { box-shadow: inset -1px 0 0 var(--border); } .table[data-lock-end] tr > :last-child { box-shadow: inset 1px 0 0 var(--border); } .table-row:hover > :is(.table-cell) { --_lock-bg: color-mix(in oklch, var(--muted) 100%, var(--background)); } .table-row[aria-selected="true"] > .table-cell { --_lock-bg: color-mix(in oklch, var(--primary) 7%, var(--background)); } .table[data-sticky-header] thead .table-head { position: sticky; top: 0; z-index: 2; } .table-container:has(> .table[data-sticky-header]) { overflow: auto; } /* -- Actions: a .table-actions cell of buttons, end-aligned - always visible. data-actions="quiet" on the table dims them until the row is hovered or focused (touch screens: full strength). On a narrow table (container < 40rem) the inline buttons (.table-actions-inline) give way to one "more" button (.table-actions-menu) opening a native popover menu (.table-menu) - one menu in the row, no JavaScript. Touch screens get 44px targets. */ .table-actions { width: 1%; white-space: nowrap; text-align: end; & > * + *, & .table-actions-inline > * + * { margin-inline-start: 0.25rem; } } .table-actions-inline { display: inline-flex; align-items: center; } .table-actions-menu { display: none; } .table[data-actions="quiet"] :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) { opacity: 0.45; transition: opacity 150ms ease; } .table[data-actions="quiet"] .table-row:is(:hover, :focus-within) :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) { opacity: 1; } @media (hover: none) { .table[data-actions="quiet"] :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) { opacity: 1; } } @media (pointer: coarse) { .table-actions .btn[data-size^="icon"] { min-width: 2.75rem; min-height: 2.75rem; } } @container (max-width: 40rem) { .table-actions:has(.table-actions-menu) .table-actions-inline { display: none; } .table-actions-menu { display: inline-flex; } } /* the row menu: a popover anchored to its own "more" button */ .table-row { anchor-scope: --table-row-menu; } .table-actions-menu > .btn { anchor-name: --table-row-menu; } .table-menu[popover] { position: fixed; position-anchor: --table-row-menu; inset: auto; top: anchor(bottom); right: anchor(right); position-try-fallbacks: flip-block; margin: 0.25rem 0 0; min-width: 10rem; padding: 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background-color: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-lg); text-align: start; &:popover-open { display: grid; gap: 0.125rem; } } .table-menu-item { display: flex; align-items: center; gap: 0.5rem; width: 100%; min-height: 2.25rem; padding: 0.375rem 0.625rem; border: 0; border-radius: var(--radius-md); background: none; color: inherit; font: inherit; font-size: 0.875rem; text-align: start; text-decoration: none; cursor: pointer; &:hover, &:focus-visible { background-color: var(--accent); color: var(--accent-foreground); outline: none; } &[data-tone="destructive"] { color: var(--destructive); } & svg { width: 1rem; height: 1rem; } } @media (pointer: coarse) { .table-menu-item { min-height: 2.75rem; } } /* -- Empty state: one .table-empty cell spanning the columns ----------- */ .table-empty { padding: 2.5rem 1rem !important; text-align: center; color: var(--muted-foreground); & > * { display: block; margin-inline: auto; } & > * + * { margin-top: 0.375rem; } & > strong { color: var(--foreground); font-weight: 600; } & > .btn { display: inline-flex; margin-top: 1rem; } } .table-empty-icon { display: grid !important; place-items: center; width: 2.75rem; height: 2.75rem; margin-bottom: 0.5rem; border-radius: 9999px; background-color: var(--muted); color: var(--muted-foreground); & svg { width: 1.25rem; height: 1.25rem; } } tbody .table-row:has(> .table-empty):hover { background-color: transparent; } /* a dropdown button straight in the actions cell anchors its .table-menu too */ .table-actions > .btn[popovertarget] { anchor-name: --table-row-menu; } /* -- Text in cells ------------------------------------------------------- */ .table-sub { display: block; margin-top: 0.125rem; font-size: 0.8125rem; color: var(--muted-foreground); } .table-truncate { display: block; max-width: var(--table-truncate, 14rem); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .table-clamp { display: -webkit-box; max-width: var(--table-clamp, 20rem); overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; } .table-mono { font-family: var(--font-mono); font-size: 0.8125rem; white-space: nowrap; } .table-muted { color: var(--muted-foreground); } /* -- Container query: compact table in narrow containers -- */ @container (max-width: 480px) { .table-head, .table-cell { padding: 0.5rem 0.75rem; font-size: 0.8125rem; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .table, .table *, .table::before, .table::after, .table *::before, .table *::after, .table::backdrop, .table-caption, .table-caption *, .table-caption::before, .table-caption::after, .table-caption *::before, .table-caption *::after, .table-caption::backdrop, .table-cell, .table-cell *, .table-cell::before, .table-cell::after, .table-cell *::before, .table-cell *::after, .table-cell::backdrop, .table-container, .table-container *, .table-container::before, .table-container::after, .table-container *::before, .table-container *::after, .table-container::backdrop, .table-head, .table-head *, .table-head::before, .table-head::after, .table-head *::before, .table-head *::after, .table-head::backdrop, .table-row, .table-row *, .table-row::before, .table-row::after, .table-row *::before, .table-row *::after, .table-row::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JS view file
/* -- Table component --------------------------------------------- */// The table is CSS; this module adds what a data table does on top: column// sorting (a .table-sort button in the header, aria-sort, Intl.Collator// numeric order), row selection (a .table-select checkbox column with a// select-all header box - indeterminate when some are chosen, Shift+click// for ranges), row reordering (a .table-handle grip, native Drag and Drop,// Alt+ArrowUp / Alt+ArrowDown), the offsets of locked columns// (data-lock-start) and the named State API (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = as authored (original order, no sort, nothing selected); * sorted = { column, direction }; selected = { rows: [indices] | 'all' }. */const tableStates = ['default', 'sorted', 'selected'];const bodyOf = (table) => table.tBodies[0];const bodyRows = (table) => [...(bodyOf(table)?.rows ?? [])];const rowBox = (row) => row.querySelector(':scope > .table-select input[type="checkbox"]');const headBox = (table) => table.tHead?.querySelector('.table-select input[type="checkbox"]');/* -- Sorting --------------------------------------------------------------- */function cellValue(row, col) { const cell = row.cells[col]; if (!cell) return ''; return cell.dataset.sortValue ?? cell.textContent.trim();}function sortBy(table, col, direction) { const body = bodyOf(table); if (!body) return; const lang = table.closest('[lang]')?.lang || undefined; const collator = new Intl.Collator(lang, { numeric: true, sensitivity: 'base' }); const dir = direction === 'descending' ? -1 : 1; const rows = bodyRows(table); rows.sort((a, b) => { const x = cellValue(a, col); const y = cellValue(b, col); const nx = Number(x); const ny = Number(y); const c = x !== '' && y !== '' && Number.isFinite(nx) && Number.isFinite(ny) ? nx - ny : collator.compare(x, y); return c * dir; }); body.append(...rows); [...(table.tHead?.rows[0]?.cells ?? [])].forEach((th, i) => { if (th.querySelector('.table-sort')) th.setAttribute('aria-sort', i === col ? direction : 'none'); }); table._sort = { column: col, direction };}function unsort(table) { const body = bodyOf(table); if (body && table._original) body.append(...table._original.filter((r) => r.parentElement === body)); table.tHead?.querySelectorAll('[aria-sort]').forEach((th) => th.setAttribute('aria-sort', 'none')); table._sort = null;}/* -- Selection ------------------------------------------------------------- */function syncSelection(table, announce = true) { const rows = bodyRows(table); const boxes = rows.map(rowBox).filter(Boolean); rows.forEach((row) => { const box = rowBox(row); if (box) row.setAttribute('aria-selected', String(box.checked)); }); const head = headBox(table); if (head) { const on = boxes.filter((b) => b.checked).length; head.checked = boxes.length > 0 && on === boxes.length; head.indeterminate = on > 0 && on < boxes.length; } if (announce) { const selected = rows.filter((r) => r.getAttribute('aria-selected') === 'true'); table.dispatchEvent(new CustomEvent('table-select', { bubbles: true, detail: { rows: selected, count: selected.length } })); }}function selectRows(table, which) { const rows = bodyRows(table); rows.forEach((row, i) => { const on = which === 'all' || (Array.isArray(which) && which.includes(i)); const box = rowBox(row); if (box) box.checked = on; row.setAttribute('aria-selected', String(on)); }); syncSelection(table);}/* -- Reordering ------------------------------------------------------------ */function announceMove(table, row) { table.dispatchEvent(new CustomEvent('table-reorder', { bubbles: true, detail: { row, index: bodyRows(table).indexOf(row) } }));}function moved(table, row) { // a manual order is no longer the sorted one table.tHead?.querySelectorAll('[aria-sort]').forEach((th) => th.setAttribute('aria-sort', 'none')); table._sort = null; announceMove(table, row);}function initReorder(table) { let dragged = null; const clear = () => table.querySelectorAll('[data-drop]').forEach((r) => r.removeAttribute('data-drop')); // a row drags only from its grip - text in the other cells stays selectable table.addEventListener('pointerdown', (e) => { const handle = e.target.closest?.('.table-handle'); if (handle) handle.closest('tr').draggable = true; }); table.addEventListener('dragstart', (e) => { const row = e.target.closest?.('tbody > tr'); if (!row || !row.draggable) return; dragged = row; row.dataset.dragging = ''; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.cells[1]?.textContent.trim() ?? ''); }); table.addEventListener('dragover', (e) => { const row = e.target.closest?.('tbody > tr'); if (!dragged || !row || row === dragged) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const r = row.getBoundingClientRect(); const where = e.clientY - r.top < r.height / 2 ? 'before' : 'after'; if (row.dataset.drop !== where) { clear(); row.dataset.drop = where; } }); table.addEventListener('drop', (e) => { const row = table.querySelector('tbody > tr[data-drop]'); if (!dragged || !row) return; e.preventDefault(); row.parentElement.insertBefore(dragged, row.dataset.drop === 'before' ? row : row.nextSibling); clear(); moved(table, dragged); }); table.addEventListener('dragend', () => { if (dragged) { delete dragged.dataset.dragging; dragged.draggable = false; } dragged = null; clear(); }); // keyboard: Alt+ArrowUp / Alt+ArrowDown on anything in the row table.addEventListener('keydown', (e) => { if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return; const row = e.target.closest?.('tbody > tr'); if (!row) return; e.preventDefault(); const sib = e.key === 'ArrowUp' ? row.previousElementSibling : row.nextElementSibling; if (!sib) return; row.parentElement.insertBefore(row, e.key === 'ArrowUp' ? sib : sib.nextSibling); e.target.focus(); moved(table, row); });}/* -- Locked columns: offsets of the 2nd / 3rd locked column ------------------ */function measureLocks(table) { const n = parseInt(table.dataset.lockStart || '0', 10); if (n < 2) return; const first = table.rows[0]; if (!first) return; for (let i = 1; i < n; i++) table.style.setProperty(`--table-lock-${i}`, `${first.cells[i - 1]?.getBoundingClientRect().width ?? 0}px`);}/* -- State API --------------------------------------------------------------- */function triggerStateChange(table, stateName, config) { switch (stateName) { case 'default': unsort(table); selectRows(table, []); break; case 'sorted': sortBy(table, config?.column ?? 0, config?.direction === 'descending' ? 'descending' : 'ascending'); break; case 'selected': selectRows(table, config?.rows ?? [0]); break; }}/** Registry-level API; pass the table explicitly. Unknown names throw. */export const tableApi = { setState(table, stateName, config = {}) { if (!tableStates.includes(stateName)) { throw new Error(`table: unknown state "${stateName}" (supported: ${tableStates.join(', ')})`); } triggerStateChange(table, stateName, config); table.dataset.stateName = stateName; table._stateConfig = config; }, getState(table) { const selected = bodyRows(table).flatMap((r, i) => (r.getAttribute('aria-selected') === 'true' ? [i] : [])); return { name: table.dataset.stateName || 'default', config: { ...table._stateConfig, sort: table._sort ?? null, selected } }; },};df$.tableApi = tableApi;df$.tableStates = tableStates;function init() { document.querySelectorAll('table.table:not([data-init])').forEach((table) => { table.dataset.init = ''; table.dataset.stateName = 'default'; table._original = bodyRows(table); table._sort = null; table.api = { setState: (stateName, config) => tableApi.setState(table, stateName, config), getState: () => tableApi.getState(table), }; // sorting: a click on a .table-sort button cycles ascending → descending → as authored table.tHead?.querySelectorAll('.table-sort').forEach((btn) => { const th = btn.closest('th'); if (!th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none'); btn.addEventListener('click', () => { const col = th.cellIndex; const now = th.getAttribute('aria-sort'); const next = now === 'ascending' ? 'descending' : now === 'descending' ? 'none' : 'ascending'; if (next === 'none') unsort(table); else sortBy(table, col, next); table.dataset.stateName = next === 'none' ? 'default' : 'sorted'; table.dispatchEvent(new CustomEvent('table-sort', { bubbles: true, detail: { column: col, direction: next } })); }); }); // an authored aria-sort sorts on load const pre = table.tHead?.querySelector('th[aria-sort="ascending"], th[aria-sort="descending"]'); if (pre) sortBy(table, pre.cellIndex, pre.getAttribute('aria-sort')); // selection if (table.querySelector('.table-select input[type="checkbox"]')) { let last = null; table.addEventListener('click', (e) => { const box = e.target.closest?.('.table-select input[type="checkbox"]'); if (!box) return; if (box === headBox(table)) { const on = box.checked; bodyRows(table).forEach((r) => { const b = rowBox(r); if (b && !b.disabled) b.checked = on; }); } else { // Shift+click: the range from the last clicked row takes this state const rows = bodyRows(table); const row = box.closest('tr'); if (e.shiftKey && last && rows.includes(last)) { const [a, b] = [rows.indexOf(last), rows.indexOf(row)].sort((x, y) => x - y); rows.slice(a, b + 1).forEach((r) => { const rb = rowBox(r); if (rb && !rb.disabled) rb.checked = box.checked; }); } last = row; } syncSelection(table); table.dataset.stateName = bodyRows(table).some((r) => r.getAttribute('aria-selected') === 'true') ? 'selected' : 'default'; }); syncSelection(table, false); } if (table.querySelector('.table-handle')) initReorder(table); if (table.dataset.lockStart) { measureLocks(table); // measured on the next frame: re-pinning the locked columns inside the // observer would change layout mid-delivery (the "ResizeObserver loop") let frame = 0; new ResizeObserver(() => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => measureLocks(table)); }).observe(table); } });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub