Table
Table wraps a native <table>. You write ordinary <thead>, <tbody>,
<tfoot>, <tr>, <th>, and <td> elements, put any content in the
cells (tags, icons, buttons, dropdown buttons, links), and eun-table
styles them and adds row selection with a floating
Dependencies
| Name | Role | Department | Status | Actions |
|---|---|---|---|---|
| Ada Lovelace | Engineering Lead | Engineering | ||
| Grace Hopper | Principal Engineer | Engineering | ||
| Alan Turing | Research Scientist | Research | ||
| Katherine Johnson | Data Scientist | Research | ||
| Margaret Hamilton | Software Architect | Engineering | ||
| Radia Perlman | Network Engineer | Infrastructure | ||
| Hedy Lamarr | Product Manager | Product | ||
| Barbara Liskov | Distinguished Engineer | Engineering |
Scroll the table above to see the header and the first column stay pinned. Hover or focus a row to reveal its checkbox and its menu, then select one or more rows to bring up the floating action bar with a live count, "Select all", and three custom actions.
When to use
Reach for table to show records in rows and columns, whether they're a static reference (a pricing grid, a legal retention schedule) or data a user acts on (selecting rows for a bulk action, opening a row, triggering a per-row menu). A table with none of its optional behaviors is a plain, read-only table, so the same element covers both.
Write the <table> yourself whenever you render your rows from a template
(a server page, a framework component, a CMS). Hand eun-table a
columns/data pair instead when your rows are an array in a plain
script and you'd rather not build the markup by hand.
Table has no sorting, filtering, or pagination of its own. Sort your own
data, reflect it with aria-sort on the header cell, and pair the table
with
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/table.js";
<eun-table selectable aria-label="Team members">
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col" style="width: 120px">Status</th>
</tr>
</thead>
<tbody>
<tr data-row-key="ada">
<td>Ada Lovelace</td>
<td><eun-tag severity="success">Active</eun-tag></td>
</tr>
</tbody>
</table>
</eun-table>
<script type="module">
const table = document.querySelector("eun-table");
table.addEventListener("euntableselectionchange", (event) => {
console.log(event.selectedKeys);
});
</script>
Importing table.js registers everything the table renders on its own:
the selection checkboxes, the action bar, the loading placeholders, and
the buttons generated by rowAction. Import every other element you put
in a cell yourself, such as tag.js or dropdown-button.js.
Every text the table renders (the checkbox labels, the action bar, the
loading announcements) follows locale, or the page default set with
setDefaultLocale(). Set one of the *-label attributes to override a
single text.
Server rendering
The table is in the light DOM, so it's already readable before the
component loads. To have it look final too, link table-ssr.css: it holds
the exact rules eun-table applies once registered. Until then, it also
reserves what the component adds on upgrade, so the table doesn't move:
the selection column while selectable is set, the loading rows while
loading is set (skeleton-rows from 1 to 12), and --table-max-height.
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/table-ssr.css"
/>
import "@eunomia/elements/table-ssr.css";
Responsive behavior
eun-table is its own scroll container, so a table wider than the space
it's given scrolls horizontally on any viewport with no extra setup.
sticky-first-column keeps the identifying column in view while the rest
scrolls underneath, the selection column stays pinned too, and
--table-max-height bounds the height so long tables scroll vertically
instead of pushing the page down. The floating action bar spans the screen
and wraps onto more than one line below 600px, and the table keeps that
room free under itself so the bar never hides its last rows.
While the table scrolls sideways, a soft shadow marks each edge with more
columns past it, drawn beside any sticky column. A scrolling table can
also take keyboard focus and be scrolled with the arrow keys, announced
by its <caption> or aria-label.
The table below is held to a phone's width. Scroll it sideways: the order number stays pinned, a shadow shows the columns left on each side, and the header stays in view as it scrolls down. Tab to it to scroll it with the arrow keys.
| Order | Customer | City | Total | Date | Status |
|---|---|---|---|---|---|
| A-1042 | Ada Lovelace | London | € 389.00 | 2026-10-01 | |
| A-1043 | Alan Turing | Manchester | € 459.00 | 2026-10-02 | |
| A-1044 | Grace Hopper | New York | € 1,120.00 | 2026-10-03 | |
| A-1045 | Hedy Lamarr | Vienna | € 612.50 | 2026-10-04 | |
| A-1046 | Katherine Johnson | Hampton | € 248.90 | 2026-10-05 | |
| A-1047 | Margaret Hamilton | Boston | € 975.00 | 2026-10-06 |
<eun-table sticky-header sticky-first-column style="--table-max-height: 240px;">
<table>
<caption>
Recent orders
</caption>
<thead>
<tr>
<th scope="col">Order</th>
<!-- … more columns, wider than the table … -->
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr data-row-key="A-1042">
<th scope="row">A-1042</th>
<!-- … -->
</tr>
</tbody>
</table>
</eun-table>
Guidance
- Give every header cell
scope="col", and the identifying cell of each row<th scope="row"> - Set a stable
data-row-keyon every body row once rows can be sorted, filtered, or paginated - Name the table with a
<caption>or anaria-labelwhenever a page holds more than one - Keep a row end action to a single button or dropdown button with its own
aria-label - Set
--table-max-heightwhenever the header or footer is sticky, so the table has something to scroll
- Building layouts with a table: it's for tabular data only
- Putting a row end action without a matching header cell: the columns would no longer line up
- Pinning both the first and the last column on a narrow table: the two sticky bands can cover the scrolling middle
- Expecting built-in sort, filter, or pagination: sort your own rows and pair the table with
eun-pagination
Live testing
Properties
Table <eun-table>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| row-numbers | boolean | false | Shows each body row's 1-based position in a generated leading column, after the selection column |
| row-number-aria-label | string | 'Row number' | Accessible label of the row number column's header ; translated from `locale` while unset |
| checkbox-always-visible | boolean | false | Keeps every row's own checkbox visible at all times instead of only on hover/focus/selection |
| action-always-visible | boolean | false | Keeps every row end action (a `data-table-action` cell's content) visible at all times instead of only on hover/focus ; still hidden while rows are selected |
| sticky-header | boolean | false | Pins the `<thead>` to the top of the table's own scroll container |
| sticky-footer | boolean | false | Pins the `<tfoot>` to the bottom of the table's own scroll container |
| sticky-first-column | boolean | false | Pins the first data column to the start edge |
| sticky-last-column | boolean | false | Pins the last data column, and the row end action column, to the end edge |
| hide-action-bar | boolean | false | Keeps the floating action bar from showing while rows are selected, for selection controls of your own built on selectedKeys and euntableselectionchange |
| action-bar-viewport-centered-below | 'mobile' | 'tablet' | 'laptop' | 'desktop' | number | — | Centers the action bar on the screen instead of the table below this width, a breakpoint name or a number of pixels. --table-action-bar-viewport-centered-below sets it from CSS too |
| collapsible-action-bar | boolean | false | Shrinks the action bar's count badge and select-all button to their number and icon when the bar lacks room |
| clear-on-escape | boolean | false | Whether `Escape` clears the selection while rows are selected |
| selected-label | string | 'selected' | Text shown after the count in the action bar's badge ; translated from `locale` while unset |
| select-all-label | string | 'Select all' | Label of the action bar's select-all button while not every row is selected ; translated from `locale` while unset |
| deselect-all-label | string | 'Deselect all' | Label of the action bar's select-all button once every row is selected ; translated from `locale` while unset |
| select-all-aria-label | string | 'Select all rows' | Accessible label for the header's select-all checkbox while not every row is selected ; translated from `locale` while unset |
| deselect-all-aria-label | string | 'Deselect all rows' | Accessible label for the header's select-all checkbox once every row is selected ; translated from `locale` while unset |
| select-row-aria-label | string | 'Select row' | Accessible label prefix for each row's own checkbox, followed by the row header's text, or its 1-based row number without one ; translated from `locale` while unset |
| toolbar-aria-label | string | 'Selection actions' | Accessible label for the floating action bar itself ; translated from `locale` while unset |
| close-action-bar-aria-label | string | 'Clear selection' | Accessible label for the action bar's close button ; translated from `locale` while unset |
| skeleton-rows | number | 6 | Number of placeholder rows rendered while `loading` |
| page-size | number | 20 | Number of rows per page, for both `eunloadmore` and `virtualize` |
| virtualize-threshold | number | 100 | Row count above which `virtualize` starts detaching pages |
| buffer-pages | number | 1 | Number of extra pages kept mounted before and after the page in view |
| has-more | boolean | false | Whether more rows can still be obtained |
| loading-more | boolean | false | Whether a `eunloadmore` request is in flight ; set to `true` right before the event fires, reset by the consumer once rows are appended |
| root-margin | string | '400px' | How far before the end of the table `eunloadmore` triggers, as a CSS-like margin |
| loading-more-label | string | 'Loading more rows…' | Accessible label passed to the trailing `eun-loader` ; translated from `locale` while unset |
| selectable | boolean | false | Enables row selection (generated checkbox column, select-all, the floating action bar) |
| loading | boolean | false | Shows `skeletonRows` placeholder rows in place of the body rows ; the header stays visible |
| syncing | boolean | false | Sweeps a shimmer across every visible body row without hiding any, for a background refresh. Sets `aria-busy` on the table while set |
| density | 'compact' | 'comfortable' | — | Tightens or loosens the vertical padding of every row, header included. Left unset, rows keep the default spacing. The padding custom properties still take precedence |
| clickable | boolean | false | Shows a pointer cursor on body rows ; a row click fires `euntablerowclick` and follows the row's `data-table-row-link` link regardless, which stays the keyboard way to do the same |
| infinite | boolean | false | Enables scroll-triggered pagination : dispatches `eunloadmore` once the user scrolls near the end of the table, in its own scroll container or the page, and `hasMore` is `true` |
| virtualize | boolean | false | Detaches body row pages far from the current scroll position once the row count exceeds `virtualizeThreshold`, each replaced by a spacer row of the same height |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| actions | Array<EunomiaTableAction> | [] | Custom actions rendered in the floating action bar's center region |
| columns | Array<EunomiaTableColumnDef<T>> | — | Programmatic alternative to authoring the `<table>` : when set, `eun-table` renders the native table itself from `columns`/`data`, and owns its light DOM from then on |
| data | Array<T> | [] | The rows to render, in "managed" mode (see `columns`) |
| rowAction | EunomiaTableRowActionDef<T> | — | A single row end action (button or dropdown), in "managed" mode |
| rowKey | (row: T, index: number) => string | — | Computes each row's stable key, in "managed" mode ; defaults to a DOM-order fallback |
| selectedKeys | Array<string> | [] | The currently selected rows' keys ; settable from outside for a "controlled" table, without firing euntableselectionchange |
| loadingBeforeUpgrade | — | ||
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | A native `<table>`, with a `<thead>`, a `<tbody>`, and an optional `<caption>` and `<tfoot>` |
| empty | Shown below the header while the body has no row and the table isn't loading, such as a "No results" message |
Events
| Name | Type | Description |
|---|---|---|
| euntableselectionchange | TableSelectionChangeEvent | Emitted whenever the user changes the selection, through a checkbox, select-all, a row click, the action bar, or Escape. Not emitted when selectedKeys is set |
| euntableaction | TableActionEvent | Emitted when a floating action bar action is activated |
| euntablerowclick | TableRowClickEvent | Emitted on a body row click outside its checkbox, its action cell, and any interactive content, while no row is selected ; cancelable, `preventDefault()` keeps the row's `data-table-row-link` link from being followed |
| eunloadmore | LoadMoreEvent | Dispatched when `infinite` is set, more rows are needed, and `hasMore` is `true` |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --table-border-color | Sets the border color of the frame and the rows |
| --table-border-width | Sets the border width of the frame and the row dividers |
| --table-border-radius | Sets the corner radius of the frame |
| --table-empty-padding | Sets the padding around the empty slot's content |
| --table-empty-color | Sets the text color of the empty slot's content |
| --table-background | Sets the background color of the frame |
| --table-max-height | Sets a max height, scrolling past it ; `none` by default |
| --table-scroll-shadow | Sets the color of the shadow on an edge with more columns to scroll to |
| --table-scroll-shadow-size | Sets the width of that shadow ; `12px` by default |
| --table-focus-outline | Sets the focus outline color of the frame, focusable once it scrolls |
| --table-caption-color | Sets the text color of the `<caption>` |
| --table-caption-font-size | Sets the font size of the `<caption>` |
| --table-caption-font-weight | Sets the font weight of the `<caption>` |
| --table-header-background | Sets the background color of the header cells |
| --table-footer-background | Sets the background color of the footer cells |
| --table-row-background | Sets the background color of the body cells |
| --table-row-hover-background | Sets the tint of a hovered or focused body row |
| --table-row-selected-background | Sets the tint of a selected body row |
| --table-row-dragging-background | Sets the tint of a row being reordered with `createDragReorder` |
| --table-row-transition-duration | Sets the duration of the hover and selection tint transitions |
| --table-header-color | Sets the text color of the header cells |
| --table-column-color | Sets the text color of the header cells, while `--table-header-color` is unset |
| --table-column-font-weight | Sets the font weight of the header cells |
| --table-header-padding-block | Sets the vertical padding of the header cells, the body cells' own by default |
| --table-header-padding-inline | Sets the horizontal padding of the header cells, the body cells' own by default |
| --table-header-font-size | Sets the font size of the header cells, the body cells' own by default |
| --table-header-text-transform | Sets the text transform of the header cells, such as uppercase |
| --table-header-letter-spacing | Sets the letter spacing of the header cells |
| --table-cell-color | Sets the text color of the cells |
| --table-cell-padding-block | Sets the vertical padding of the cells. Takes precedence over density |
| --table-cell-padding-inline | Sets the horizontal padding of the cells |
| --table-cell-font-size | Sets the font size of the cells |
| --table-row-min-height | Sets a minimum height for every body row, so rows keep the same height whatever their content |
| --table-column-border-color | Sets the color of a vertical divider between columns ; transparent by default |
| --table-sticky-column-background | Sets the background color of a sticky column's cells, and of the selection column |
| --table-sticky-row-background | Sets the background color of the footer cells while `stickyFooter` |
| --table-sticky-border-color | Sets the color of the edges of a sticky header, footer, or column |
| --table-checkbox-background | Sets the background color of an unchecked selection checkbox |
| --table-checkbox-border-color | Sets the border color of an unchecked selection checkbox |
| --table-checkbox-hover | Sets the background color of an unchecked selection checkbox while hovered |
| --table-checkbox-size | Sets the size of the selection checkboxes |
| --table-checkbox-border-radius | Sets the corner radius of the selection checkboxes |
| --table-action-color | Sets the text and icon color of a row end action |
| --table-action-icon-size | Sets the icon size of a row end action button, which also sets its line height |
| --eun-table-select-col-width | Sets the width of the selection column ; 40px by default |
| --eun-table-row-number-col-width | Sets the width of the row number column ; 48px by default |
| --table-row-number-color | Sets the text color of the row numbers |
| --eun-table-action-col-width | Sets the width of the row end action column, which stays fixed so a sticky last column lines up with it ; 56px by default, enough for an icon-only button |
| --table-skeleton-row-height | Sets the height of the placeholder rows shown while loading, the row minimum height by default. Also reserves their room before eun-table is defined |
| --table-skeleton-stagger | Delay between each placeholder row's entrance animation, as a CSS time (e.g. `60ms`) |
| --table-skeleton-duration | Duration of each placeholder row's entrance animation, as a CSS time (e.g. `320ms`) |
| --table-loader-color | Sets the color of the trailing `eun-loader` shown while `loadingMore` |
| --table-sync-overlay-color | Sets the highlight color of the `syncing` shimmer sweep |
| --table-sync-duration | Sets the duration of the `syncing` shimmer sweep, as a CSS time (default `1.6s`) |
CSS shadow parts
| Name | Description |
|---|---|
| frame | The bordered scroll container around the table |
Markup conventions
| Markup | Meaning |
|---|---|
<tr data-row-key="…"> |
The row's stable identity, used for selection and euntablerowclick. Falls back to its DOM order |
<th> / <td data-table-action> |
The row end action column, revealed on hover or focus and hidden while rows are selected |
data-table-row-link |
The row's main link, which a click anywhere on the row follows |
<tfoot> |
Summary rows (totals), kept out of the selection, pinned with sticky-footer |
slot="empty" |
The message shown while the body has no row, such as "No results" |
<th scope="row"> |
A row header, announced along with every cell of its row |
aria-sort on a <th> |
The column's sort state, presentation only |
.eun-table-visually-hidden |
Hides a label visually while keeping it for assistive technology, such as the action column's name |
eun-table generates the selection column itself: a <th data-table-select>
holding the select-all checkbox in the header, a <td data-table-select>
holding each row's checkbox, and an empty one in each footer row. While
loading, it also adds a <tbody data-table-skeleton> of placeholder rows.
The eun-table-action-bar API is documented on its
Data
Read-only table
With none of its optional behaviors, eun-table is a styled native table:
no focus stop, no generated cell, nothing to interact with. A <caption>
names it, and shows above the table's frame while staying part of the
table for assistive technology.
| Data | Purpose | Retention |
|---|---|---|
| Contact form | Answering your request | 3 years |
| Orders | Accounting obligations | 10 years |
| Analytics cookies | Audience measurement | 13 months |
<eun-table>
<table>
<caption>
Data retention
</caption>
<thead>
<tr>
<th scope="col">Data</th>
<th scope="col">Purpose</th>
<th scope="col" style="width: 160px">Retention</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Contact form</th>
<td>Answering your request</td>
<td>3 years</td>
</tr>
</tbody>
</table>
</eun-table>
Programmatic columns and data
Set columns, data, and rowKey, and eun-table renders the same
native table itself. A column's render returns any content, such as a
template, a node, or a string, and rowAction adds a row end button or
dropdown button computed per row. Setting columns hands the table's
light DOM over to eun-table, so don't also write rows by hand.
const table = document.querySelector("eun-table");
table.columns = [
{ key: "name", label: "Name" },
{ key: "role", label: "Role", width: "200px" },
{ key: "status", label: "Status", width: "120px", render: statusTag },
];
table.data = rows;
table.rowKey = (row) => row.id;
table.rowAction = {
type: "dropdown",
label: "Row actions",
icon: "more_horiz",
options: () => [
{ key: "edit", label: "Edit" },
{ key: "delete", label: "Delete" },
],
onSelect: (key, row) => console.log(key, row.name),
};
In a Lit template, bind the same values as properties:
html`<eun-table
.columns=${columns}
.data=${team}
.rowKey=${(row) => row.id}
></eun-table>`;
Columns
Column widths and alignment
Columns follow the native table layout: they size to their content and
share the remaining width. Set a width on a header cell, or use a
<colgroup>, to fix one, and text-align on the cells to align a column.
A width still shrinks when space runs out; set a min-width on the
header cell instead to keep the column wide, so the table scrolls
horizontally.
| Product | Quantity | Price |
|---|---|---|
| Mixer tap | 2 | € 189.00 |
| Shower column | 1 | € 459.00 |
<table>
<colgroup>
<col />
<col style="width: 120px" />
<col style="width: 140px" />
</colgroup>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col" style="text-align: end">Quantity</th>
<th scope="col" style="text-align: end">Price</th>
</tr>
</thead>
...
</table>
Sticky header, footer, and columns
sticky-header pins the <thead>, sticky-footer pins the <tfoot>,
and sticky-first-column and sticky-last-column pin the first and last
data columns. They stick to eun-table itself, so bound its height with
--table-max-height.
| Month | Orders | Returns | Revenue |
|---|---|---|---|
| January | 120 | 4 | € 18,400 |
| February | 98 | 2 | € 15,100 |
| March | 134 | 6 | € 21,250 |
| April | 142 | 3 | € 22,900 |
| May | 151 | 5 | € 24,300 |
| June | 127 | 4 | € 19,800 |
| Total | 772 | 24 | € 121,750 |
<eun-table
sticky-header
sticky-footer
sticky-first-column
style="--table-max-height: 220px;"
>
<table>
<thead>
...
</thead>
<tbody>
...
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>772</td>
<td>24</td>
<td>€ 121,750</td>
</tr>
</tfoot>
</table>
</eun-table>
Sticky columns
When the columns are wider than the table, scroll it horizontally:
sticky-first-column keeps the first data column pinned to the start edge,
right after the selection column, and sticky-last-column keeps the last
data column pinned to the end edge, right before the row end action
column, which stays pinned with it.
| Order | Customer | City | Items | Total | Payment | Date | Status | Actions |
|---|---|---|---|---|---|---|---|---|
| A-1042 | Ada Lovelace | London | 2 | € 389.00 | Card | 2026-10-01 | ||
| A-1043 | Alan Turing | Manchester | 1 | € 459.00 | Transfer | 2026-10-02 | ||
| A-1044 | Grace Hopper | New York | 4 | € 1,120.00 | Card | 2026-10-03 | ||
| A-1045 | Hedy Lamarr | Vienna | 3 | € 612.50 | PayPal | 2026-10-04 |
<eun-table selectable sticky-first-column sticky-last-column>
<table>
<thead>
<tr>
<th scope="col" style="min-width: 160px">Order</th>
<!-- … more columns, wider than the table … -->
<th scope="col" style="min-width: 160px">Status</th>
<th scope="col" data-table-action>
<span class="eun-table-visually-hidden">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr data-row-key="A-1042">
<th scope="row">A-1042</th>
<!-- … -->
<td><eun-tag severity="success">Shipped</eun-tag></td>
<td data-table-action>…</td>
</tr>
</tbody>
</table>
</eun-table>
Row selection
selectable adds a checkbox column. A row's checkbox shows on hover or
focus, and stays visible once any row is selected. On touch screens and
up to tablet width, where there's no hover to reveal it, every checkbox
is always shown. While rows are selected, a click anywhere on a row toggles it, and the floating action
bar shows the count, a "Select all" toggle, your actions, and a close
button. Set clear-on-escape to also clear the selection with Escape.
| Name | Status |
|---|---|
| Ada Lovelace | |
| Alan Turing | |
| Grace Hopper |
Selected: none
<eun-table selectable clear-on-escape>
<table>
<thead>
...
</thead>
<tbody>
<tr data-row-key="ada">
...
</tr>
<tr data-row-key="alan">
...
</tr>
</tbody>
</table>
</eun-table>
<script type="module">
const table = document.querySelector("eun-table");
table.actions = [
{ key: "export", label: "Export", icon: "cloud_download" },
{
key: "status",
label: "Set status",
dropdown: [
{ key: "active", label: "Active" },
{ key: "away", label: "Away" },
],
},
];
table.addEventListener("euntableaction", (event) => {
console.log(event.key, event.optionKey, table.selectedKeys);
});
</script>
Set selectedKeys to control the selection from outside. Like a native
field's value, setting it doesn't fire euntableselectionchange, which
only reports the user's own changes, so a controlled table can write back
what the event reported without looping.
table.addEventListener("euntableselectionchange", (event) => {
setSelection(event.selectedKeys);
});
// later, from your own state
table.selectedKeys = selection;
Action bar on narrow screens
When the actions don't fit, the action bar's center scrolls sideways.
Add collapsible-action-bar to shrink its count badge and select-all
button to their number and icon first, once the bar lacks room. See
<eun-table selectable collapsible-action-bar></eun-table>
Custom selection controls
hide-action-bar keeps the floating action bar from showing, for
selection controls of your own, such as a toolbar above the table. The
checkboxes, row clicks, and clear-on-escape keep working the same.
Build the controls on selectedKeys and euntableselectionchange:
the event reports the user's changes, and since setting selectedKeys
doesn't fire it, refresh your controls right after setting it.
| Name | Status |
|---|---|
| Ada Lovelace | |
| Alan Turing | |
| Grace Hopper | |
| Katherine Johnson |
<div class="selection-toolbar">
<span id="count"></span>
<eun-button id="select-all">Select all</eun-button>
<eun-button id="clear">Clear</eun-button>
<eun-button id="archive" color="critical">Archive</eun-button>
</div>
<eun-table selectable hide-action-bar clear-on-escape>
<table>
...
</table>
</eun-table>
<script type="module">
const table = document.querySelector("eun-table");
const refresh = () => {
const selected = table.selectedKeys.length;
count.textContent = `${selected} selected`;
archive.disabled = selected === 0;
};
// Only the user's own changes fire the event
table.addEventListener("euntableselectionchange", refresh);
// Setting selectedKeys doesn't, so refresh right after
clear.addEventListener("click", () => {
table.selectedKeys = [];
refresh();
});
</script>
Rows
Row numbers
row-numbers shows the position of each row in a narrow column before the
data, right after the selection checkboxes when the table is selectable.
Numbers follow the current order of the rows, so they update on their own
when rows are sorted, reordered, or loaded with infinite scroll. The column
stays pinned to the start edge, its header reads "#" and is announced as
"Row number", a label row-number-aria-label replaces.
| Name | Status |
|---|---|
| Ada Lovelace | |
| Grace Hopper | |
| Linus Torvalds |
<eun-table row-numbers>
<table>
…
</table>
</eun-table>
Row end actions
Mark the last cell of each row with data-table-action and put a single
button or dropdown button in it. The action shows on hover or focus, and
is hidden and inert while rows are selected, ceding its place to the
action bar. Give the column a header cell too, with a visually hidden
name, so the columns line up and the column is announced.
The button can sit in an eun-tooltip naming it, which is shown and
hidden along with it. While its dropdown menu is open, the row keeps its
hover look, so the action stays visible as the pointer moves onto the
menu.
| File | Size | Actions |
|---|---|---|
| 2.4 MB | ||
| 840 KB |
<tr data-row-key="photo">
<td>photo.jpg</td>
<td>840 KB</td>
<td data-table-action>
<eun-dropdown-button
size="xs"
appearance="ghost"
icon="more_horiz"
aria-label="Actions for photo.jpg"
>
<eun-dropdown-item slot="items" label="Rename"></eun-dropdown-item>
<eun-dropdown-item slot="items" label="Delete"></eun-dropdown-item>
</eun-dropdown-button>
</td>
</tr>
On touch screens and up to tablet width, every action is always shown,
since there's no hover to reveal it. action-always-visible keeps every
action visible on larger screens too, and checkbox-always-visible does
the same for the selection checkboxes.
Clickable rows
A click on a body row fires euntablerowclick with its key, unless it
lands on the checkbox, the action cell, or interactive content such as a
link or a button. Mark the row's main link with data-table-row-link and
the row click follows it too, keeping Ctrl/Cmd for a new tab. That
link is how keyboard and screen reader users open the row: the row
itself is never a focus stop, since a row has no role to activate.
clickable shows a pointer cursor. Call preventDefault() on
euntablerowclick to handle the click yourself instead of following the
link.
| Order | Customer | Status |
|---|---|---|
| Ada Lovelace | ||
| Alan Turing |
Opened: none
<eun-table clickable>
<table>
<thead>
...
</thead>
<tbody>
<tr data-row-key="A-1042">
<th scope="row">
<a href="/orders/A-1042" data-table-row-link>A-1042</a>
</th>
<td>Ada Lovelace</td>
</tr>
</tbody>
</table>
</eun-table>
Reordering rows
eun-table's rows are native <tr>s, so the
createDragReorder<tbody> like to any list. Grab a row by its
handle and drag it, or focus the handle and press Space, then the arrow
keys, then Space again. The handle can be any focusable element, an
eun-button here. Near the edge of the table, dragging scrolls the table
itself, and the selection follows the rows through their data-row-key.
| Reorder | Name | Team |
|---|---|---|
| Ada Lovelace | Platform | |
| Alan Turing | Research | |
| Grace Hopper | Compilers | |
| Katherine Johnson | Data | |
| Margaret Hamilton | Flight software |
Order:
<eun-table>
<table>
<thead>
<tr>
<th scope="col" style="width: 48px">
<span class="eun-table-visually-hidden">Reorder</span>
</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr data-row-key="ada">
<td>
<eun-button
class="drag-handle"
size="xs"
appearance="ghost"
icon="drag_indicator"
aria-label="Reorder Ada Lovelace"
></eun-button>
</td>
<th scope="row">Ada Lovelace</th>
</tr>
</tbody>
</table>
</eun-table>
import { createDragReorder } from "@eunomia/elements";
const body = document.querySelector("eun-table tbody");
createDragReorder(body, {
items: () => Array.from(body.rows),
handle: ".drag-handle",
onReorder: ({ order }) => {
saveOrder(order.map((row) => row.dataset.rowKey));
},
});
virtualize takes rows out of the DOM, which createDragReorder can't
account for: keep reordering to tables whose rows are all rendered.
Loading and empty states
Loading and syncing
loading hides the body rows and shows skeleton-rows placeholder rows
(6 by default), shaped after the header so nothing shifts once data
arrives. syncing keeps the rows visible and sweeps a shimmer across
them, for a background refresh, and sets aria-busy on the table.
| Name | Status |
|---|---|
| Ada Lovelace | |
| Alan Turing |
table.loading = true;
const rows = await fetchRows();
renderRows(rows);
table.loading = false;
Empty state
Content slotted into empty shows below the header, across the whole
frame, while the body has no row. It stays out of the selection, the
select-all checkbox is disabled, and the columns keep their place. It's
hidden while loading, so the placeholder rows show instead, and works
the same in programmatic mode once data is empty. The host carries an
empty attribute meanwhile, for any styling of your own.
| Name | Status |
|---|
No results match your filters.
<eun-table selectable>
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
<p slot="empty">No results match your filters.</p>
</eun-table>
Infinite scroll and virtualization
infinite fires eunloadmore once the user scrolls near the end of the
table and has-more is set, sets loadingMore right before, and shows a
trailing loader until you reset it. Append the new rows to the <tbody>,
then set loadingMore back to false, and hasMore to false once
there's nothing left.
| # | Result |
|---|
table.addEventListener("eunloadmore", async (event) => {
const { rows, hasMore } = await fetchPage(event.page + 1, event.pageSize);
appendRows(table.querySelector("tbody"), rows);
table.loadingMore = false;
table.hasMore = hasMore;
});
Virtualize
virtualize keeps very long tables light by keeping only the rows near
the visible area in the DOM:
eun-tabletracks every row of the first<tbody>, in or out of the DOM, and splits them into pages ofpage-sizerows.- Up to
virtualize-thresholdrows (100 by default), nothing is detached. - Past it, on every scroll and resize, it finds the page at the middle of
the visible area, from each page's measured height, or an average for
pages never measured yet, and keeps it mounted along with
buffer-pagespages on each side. - Every other page is measured, its rows are detached and kept in memory, and a single spacer row of the same height takes their place, so the scrollbar doesn't jump.
- Once a page comes back into view, the very same row nodes are put back, with their listeners and component state intact.
Selection and select-all cover every row, mounted or not, and rows you
append to the <tbody> are picked up on their own. The table must scroll
by itself, so bound its height with --table-max-height: when the page
scrolls instead, eun-table can't tell which rows are visible.
The demo below loads 5,000 rows by pages of 50 with infinite, and
virtualize keeps about 150 real rows in the DOM at most. Scroll it and
watch the counters under it.
| Order | Customer | Total | Status | Actions |
|---|
The complete page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/table-ssr.css"
/>
<script type="module">
import "@eunomia/elements/table.js";
import "@eunomia/elements/tag.js";
import "@eunomia/elements/button.js";
import "@eunomia/elements/icon.js";
</script>
</head>
<body>
<eun-table
id="orders"
selectable
sticky-header
sticky-first-column
infinite
has-more
virtualize
page-size="50"
virtualize-threshold="200"
buffer-pages="1"
style="--table-max-height: 480px;"
>
<table>
<caption>
Orders
</caption>
<thead>
<tr>
<th scope="col" style="min-width: 120px">Order</th>
<th scope="col" style="min-width: 200px">Customer</th>
<th scope="col" style="min-width: 120px">Total</th>
<th scope="col" style="min-width: 120px">Status</th>
<th scope="col" data-table-action>
<span class="eun-table-visually-hidden">Actions</span>
</th>
</tr>
</thead>
<tbody></tbody>
</table>
</eun-table>
<script type="module">
const table = document.querySelector("#orders");
const body = table.querySelector("tbody");
const TOTAL = 5000;
// Stands in for a paginated API: replace it with your own fetch.
function fetchPage(page, pageSize) {
return new Promise((resolve) => {
setTimeout(() => {
const start = (page - 1) * pageSize;
const rows = Array.from(
{ length: Math.min(pageSize, TOTAL - start) },
(_, index) => {
const id = start + index + 1;
return {
id: `C-${id}`,
customer: `Customer ${id}`,
total: (id * 7.3) % 900,
shipped: id % 3 !== 0,
};
},
);
resolve({ rows, hasMore: start + pageSize < TOTAL });
}, 400);
});
}
function renderRow(order) {
const row = document.createElement("tr");
row.dataset.rowKey = order.id;
const id = document.createElement("th");
id.scope = "row";
id.textContent = order.id;
const customer = document.createElement("td");
customer.textContent = order.customer;
const total = document.createElement("td");
total.textContent = `€ ${order.total.toFixed(2)}`;
const status = document.createElement("td");
const tag = document.createElement("eun-tag");
tag.severity = order.shipped ? "success" : "warning";
tag.textContent = order.shipped ? "Shipped" : "Pending";
status.append(tag);
const action = document.createElement("td");
action.dataset.tableAction = "";
const button = document.createElement("eun-button");
button.size = "xs";
button.appearance = "ghost";
button.setAttribute("aria-label", `Open ${order.id}`);
button.innerHTML =
'<eun-icon name="arrow_forward" size="16"></eun-icon>';
button.addEventListener("click", () => console.log("open", order.id));
action.append(button);
row.append(id, customer, total, status, action);
return row;
}
function appendRows(rows) {
const fragment = document.createDocumentFragment();
rows.forEach((order) => fragment.append(renderRow(order)));
body.append(fragment);
}
// The first page, before any scroll.
table.loading = true;
const first = await fetchPage(1, table.pageSize);
appendRows(first.rows);
table.hasMore = first.hasMore;
table.loading = false;
// event.page counts the requests: 1 on the first, then 2…
// Page 1 is already loaded, so ask for event.page + 1.
table.addEventListener("eunloadmore", async (event) => {
const { rows, hasMore } = await fetchPage(
event.page + 1,
event.pageSize,
);
appendRows(rows);
table.loadingMore = false;
table.hasMore = hasMore;
});
</script>
</body>
</html>
Since it moves rows in and out of the DOM, reserve virtualize for rows
added with plain JavaScript or rendered by columns/data: a framework
(React, Angular, Vue) re-rendering the rows would lose track of them, so
virtualize inside the framework instead. The browser's find in page only
sees the rows currently in the DOM, while assistive technology still gets
the real row count and positions through aria-rowcount and
aria-rowindex.
Customization
Density and sizing
density="compact" tightens the vertical padding of every row, header
included, and density="comfortable" loosens it. For finer control, the
header and the cells each have their own padding and font size custom
properties, and the header can switch to capitals with
--table-header-text-transform. --table-row-min-height keeps every row
at the same height whatever it holds, such as a tag or a button, and the
loading rows follow it. The frame's border and corners, the selection
checkboxes, and the row end action icon have their own sizes too, all
listed in the API tab.
| Size | Flow | Pressure |
|---|---|---|
| 1/2" | 12 L/min | 3 bar |
| 3/4" | 20 L/min | 3 bar |
| Size | Flow | Pressure |
|---|---|---|
| 1/2" | 12 L/min | 3 bar |
| 3/4" | 20 L/min | 3 bar |
<eun-table density="compact">…</eun-table>
eun-table {
--table-header-padding-block: var(--eun-space-m);
--table-header-font-size: var(--eun-font-size-xs);
--table-header-text-transform: uppercase;
--table-header-letter-spacing: 0.04em;
--table-row-min-height: 48px;
}
Custom appearance
Every color, border, and size is a CSS custom property, listed in the API
tab. --table-column-border-color adds vertical dividers between columns,
and --table-header-color sets the text color of the header.
| Size | Flow | Pressure |
|---|---|---|
| 1/2" | 12 L/min | 3 bar |
| 3/4" | 20 L/min | 3 bar |
eun-table {
--table-column-border-color: var(--eun-border-color);
--table-header-background: var(--eun-color-grey-100);
--table-header-color: var(--eun-color-primary-700);
}
A class on your own cells overrides the table's rules too, since they stay at a low specificity.
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--table-border-color |
Sets the border color of the frame and the rows |
--table-border-width |
Sets the border width of the frame and the row dividers |
--table-border-radius |
Sets the corner radius of the frame |
--table-empty-color |
Sets the text color of the empty slot's content |
--table-background |
Sets the background color of the frame |
--table-scroll-shadow |
Sets the color of the shadow on an edge with more columns to scroll to |
--table-scroll-shadow-size |
Sets the width of that shadow ; 12px by default |
--table-focus-outline |
Sets the focus outline color of the frame, focusable once it scrolls |
--table-caption-color |
Sets the text color of the <caption> |
--table-header-background |
Sets the background color of the header cells |
--table-footer-background |
Sets the background color of the footer cells |
--table-row-background |
Sets the background color of the body cells |
--table-row-hover-background |
Sets the tint of a hovered or focused body row |
--table-row-selected-background |
Sets the tint of a selected body row |
--table-row-dragging-background |
Sets the tint of a row being reordered with createDragReorder |
--table-header-color |
Sets the text color of the header cells |
--table-column-color |
Sets the text color of the header cells, while --table-header-color is unset |
--table-cell-color |
Sets the text color of the cells |
--table-column-border-color |
Sets the color of a vertical divider between columns ; transparent by default |
--table-sticky-column-background |
Sets the background color of a sticky column's cells, and of the selection column |
--table-sticky-row-background |
Sets the background color of the footer cells while stickyFooter |
--table-sticky-border-color |
Sets the color of the edges of a sticky header, footer, or column |
--table-checkbox-background |
Sets the background color of an unchecked selection checkbox |
--table-checkbox-border-color |
Sets the border color of an unchecked selection checkbox |
--table-checkbox-border-radius |
Sets the corner radius of the selection checkboxes |
--table-action-color |
Sets the text and icon color of a row end action |
--table-row-number-color |
Sets the text color of the row numbers |
--table-loader-color |
Sets the color of the trailing eun-loader shown while loadingMore |
--table-sync-overlay-color |
Sets the highlight color of the syncing shimmer sweep |
eun-table keeps the slotted <table>'s native semantics: assistive
technology meets a table with columnheader, rowheader, row, and
cell roles, and announces each cell with its headers. It adds no ARIA
role of its own and no grid keyboard navigation, so screen readers stay
in their usual reading mode and use their own table navigation commands.
A read-only table has no focus stop at all.
- Name the table with a
<caption>, or anaria-labelon<eun-table>or on the<table>. - Give header cells
scope="col", and the identifying cell of each row<th scope="row">: each row checkbox is named after it ("Select row Ada Lovelace"), falling back to the row number without one. - Reflect the sort state with
aria-sorton the sorted column's header. - Name the action column with a
.eun-table-visually-hiddenlabel, and every icon-only action with its ownaria-label. - Open a row through a real link marked
data-table-row-link, never through the row click alone.
Keyboard and focus
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves through the checkboxes, links, buttons, and other controls in the cells, in order |
Tab (after the table, rows selected) |
Reaches the action bar, which comes right after the table |
Enter (a row link) |
Opens the row, like a click on the row |
Space (a checkbox) |
Toggles that row, or every row from the header's checkbox |
Space, arrows, Space (a reorder handle) |
Picks the row up, moves it, and drops it ; Escape puts it back |
Escape (rows selected, clear-on-escape) |
Clears the selection |
Content revealed on hover stays reachable
The checkboxes and the row end actions also show on :focus-within, so a
keyboard user sees them appear when tabbing in, exactly like a mouse user
on hover. On touch screens, which have no hover, and up to tablet widths,
they're always shown.
While rows are selected, the action cells are made inert, so a hidden
action is never focusable. checkbox-always-visible and
action-always-visible show them at all times. Unchecked checkboxes keep
a border contrasted enough to be seen on any row tint.
Loading and long tables
While loading or syncing, the table carries aria-busy. With
infinite, every page of rows that arrives is announced politely ("20
more rows loaded"). With virtualize, the table carries aria-rowcount
and every row its aria-rowindex, so a screen reader announces the real
size of the table and the position of each row, even though only part of
them are in the DOM. The count is unknown (-1) while more rows can
still load.
The floating action bar
The action bar is inserted right after the table, so it comes next in
the Tab order, and shown as a popover, so it renders above the page
whatever its ancestors clip. It's a labelled region whose count is a
live status, and when it closes while it holds focus, focus moves back to
the table's first checkbox. See