HabDataTable
The product's table. Put it in a HabPanel with flush, so the panel owns the border and the table owns its own padding.
| Member | Role | Sessions | |
|---|---|---|---|
Ada Lovelace ada@example.com | Administrator | 412 | |
Grace Hopper grace@example.com | Editor | 1,204 | |
Alan Turing alan@example.com | Viewer | 38 |
<HabDataTable label="Workspace members" :columns="columns" :rows="rows" zebra>
<template #cell="{ row, column, value }">
<HabDataCell
v-if="column.key === 'person'"
:label="row.person"
:note="row.email"
/>
<template v-else>{{ value }}</template>
</template>
</HabDataTable>Reach for this the moment a second field has to align in a column. Until then, HabList is lighter and HabListRow is right when each row is a thing you open.
One primary column
primary marks the identity column: full-strength ink, medium weight, and — because it names the row — a real <th scope="row">. That is what lets a screen reader announce "briefing.wav, Size, 4.2 MB" instead of reading a bare value with no subject.
One per table. Two identity columns means neither reads as the identity, so the component warns.
It scrolls rather than compresses
A table that compresses instead of scrolling crops its trailing action column — which is usually where the destructive action lives.
minWidth defaults to the sum of the column widths, counting 140px for any column without one. Give the action column width: 40.
zebra
Banded rows. Leave it off unless the table is wide enough to lose your place; on a narrow table it is noise. Selection outranks the banding — which row the user picked matters more than keeping the stripes regular.
Grouped rows and boolean columns
| Permission | Administrator2 seatsBuilt-in | Editor9 seats | Viewer24 seats |
|---|---|---|---|
| Sessions | |||
| View sessions | Granted | Granted | Granted |
| Edit transcripts | Granted | Granted | Not granted |
| Delete sessions | Granted | Not granted | Not granted |
| Billing | |||
| View invoices | Granted | Not granted | Not granted |
| Change plan | Granted | Not granted | Not granted |
Two props turn the table into a grid you read by scanning marks rather than values: one axis runs down the primary column, the other across the headers.
groups replaces rows with labelled bands. Each heading is a <th colspan scope="colgroup">, not a styled <td>, so it is announced as the heading for the rows beneath it rather than as an empty cell.
kind: "boolean" draws a filled accent disc for true and a bare dash for false. The asymmetry is deliberate — the eye should count the filled ones.
Neither prop is tied to one kind of data. groups suits any table whose rows band into areas, and a boolean column suits any yes/no attribute.
Build a grid as a table, not as a field of discs
The obvious way to draw a grid like this is CSS grid, where every mark is an unlabelled disc inside an unnamed cell — a screen reader gets nothing at all from it.
A real <table> gives each mark a row header and a column header, and the cell spells the state out for anyone who cannot see the disc. The grid above is announced as "Delete sessions, Administrator, Granted."
Column headers
note adds a quiet second line — a seat count, a qualifier. tag adds a small uppercase label such as "Built-in". Both apply to any table, not only to a grid.
Header cells sit on the bottom of their box, so the header reads as one line against the rule however tall the cells get. They carry their own vertical padding to keep clear of it — 8px, 4px at compact, 12px at comfortable, matching the group bands. Body cells have none: the row metric already centres them.
Density
comfortable
52px — the two-line step| Member | Role | Sessions |
|---|---|---|
Ada Lovelace ada@example.com | Administrator | 412 |
Grace Hopper grace@example.com | Editor | 1,204 |
Alan Turing alan@example.com | Viewer | 38 |
default
44px| Member | Role | Sessions |
|---|---|---|
Ada Lovelace ada@example.com | Administrator | 412 |
Grace Hopper grace@example.com | Editor | 1,204 |
Alan Turing alan@example.com | Viewer | 38 |
compact
36px| Member | Role | Sessions |
|---|---|---|
Ada Lovelace ada@example.com | Administrator | 412 |
Grace Hopper grace@example.com | Editor | 1,204 |
Alan Turing alan@example.com | Viewer | 38 |
default is 44px, compact is 36px, comfortable is 52px — the same ladder as HabList and HabAccordion, so a table, a list and an accordion stack without the page reading as three different rhythms.
A table of HabDataCell rows wants comfortable. That cell is a name over a note and measures 35px, so on the 44px default it has 4.5px of air — the row is not too short for it, just tight. Every table above carries that same cell, which is why the steps read as air around it rather than as three differently built tables.
Overriding a column
cell:<key> overrides one column's cells and leaves every other column on the built-in rendering:
<HabDataTable :columns="columns" :rows="rows">
<template #cell:person="{ row }">
<HabDataCell :label="row.person" :note="row.email" />
</template>
</HabDataTable>There is also a generic cell slot, and it is worth knowing what it does differently: it replaces the built-in rendering rather than wrapping it, for every column at once. The built-in rendering is where a boolean column's disc lives, along with the sr-only text that carries the whole meaning of the mark — so filling cell to change one column silently turns both off everywhere else. Reach for it only when you really are rendering every column yourself.
Both slots receive { row, column, value }. cell:<key> wins where both are given.
Controls in a column header
header:<key> replaces one column's header content. Use it when the column is a thing rather than a field — a role, a plan, a device — and there are actions that act on it:
| Permission | Admin | Editor |
|---|---|---|
| Sessions | ||
| View sessions | Granted | Granted |
| Delete session | Granted | Not granted |
<template #header:admin="{ column }">
<span class="block">{{ column.label }}</span>
<HabRowAction
:icon="mdiPencilOutline"
:label="`Rename the ${column.label} role`"
/>
</template>The header's typography lives on the cell, not on the label, so plain text in the slot still comes out as a column header. Render the label, though: the slot replaces the cell's content, and the column's accessible name is whatever is left — every cell in the column is announced against it.
HabDataCell and HabRowAction
HabDataCell is the identity cell: a leading adornment, then a name over a quiet note. It says which thing the row is about, so it belongs in the primary column.
HabRowAction is the trailing per-row button, defaulting to the overflow dots. It is icon-only, so it needs its own label — and "Actions" is rarely the right one. Say what it acts on: Actions for Ada Lovelace.
Accessibility
labelnames the table; the component warns without one.- The primary column is each row's
<th scope="row">; group headings arescope="colgroup". - Boolean cells carry their state as text as well as a disc — but only on the built-in path. The generic
cellslot replaces that text along with the disc, so prefercell:<key>, which leaves the columns you did not name alone. - A
header:<key>slot replaces the column's name. Whatever it renders becomes the accessible name that every cell in the column is announced against, so keep the label in it and give any control there alabelof its own. - Cells truncate by default. Set
wrapon a column whose text must stay readable — a truncated value with no title attribute is unrecoverable, so preferwrapfor anything long.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | HabDataColumn[] | [] | — |
density | "default" | "comfortable" | "compact" | "default" | — |
emptyLabel | string | undefined | Shown in place of the rows when there are none. No default: pass it, or fill the empty slot, or the empty table renders a blank band. |
groups | HabDataGroup[] | undefined | Grouped rows. Wins over rows. |
label | string | undefined | Accessible name for the table. |
minWidth | string | number | undefined | Width below which the table scrolls horizontally instead of compressing. Defaults to the sum of the column widths, counting 140px for any column without one — so a table in a narrow layout scrolls rather than cropping its trailing action column. |
rows | HabDataRow[] | undefined | Flat rows. Use groups instead when the rows band into labelled areas. |
zebra | boolean | false | Banded rows. Leave it off unless the table is wide enough to lose your place — on a narrow table it is noise. |
Events
| Event | Payload | Description |
|---|---|---|
rowClick | payload: { row: HabDataRow; index: number; } | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
cell | { row: HabDataRow; column: HabDataColumn; value: unknown } | Overrides EVERY cell, in place of the built-in rendering — including the boolean disc and its screen-reader text. Prefer cell:<key> unless you really are rendering every column yourself. |
empty | any | Shown instead of the rows when there are none. |
HabDataCell
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The name. |
note | string | — | Quiet second line — an email, a path, a kind. |
Slots
| Slot | Bindings | Description |
|---|---|---|
default | any | The name, in place of label. |
leading | any | Leading HabAvatar or HabIconTile. |
HabRowAction
Props
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | — |
icon | string | undefined | SVG path data from @mdi/js. Defaults to the overflow dots. |
label | string | undefined | Accessible name. An icon-only button needs one. |
Slots
| Slot | Bindings | Description |
|---|---|---|
leading | any | Leading adornment, replacing the glyph. Takes precedence over icon. |