Skip to content

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.

MemberRoleSessions
Ada Lovelace
ada@example.com
Administrator412
Grace Hopper
grace@example.com
Editor1,204
Alan Turing
alan@example.com
Viewer38
Rows whose fields line up down the page
vue
<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 ​

PermissionAdministrator2 seatsBuilt-inEditor9 seatsViewer24 seats
Sessions
View sessionsGrantedGrantedGranted
Edit transcriptsGrantedGrantedNot granted
Delete sessionsGrantedNot grantedNot granted
Billing
View invoicesGrantedNot grantedNot granted
Change planGrantedNot grantedNot granted
Permissions by role

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
MemberRoleSessions
Ada Lovelace
ada@example.com
Administrator412
Grace Hopper
grace@example.com
Editor1,204
Alan Turing
alan@example.com
Viewer38

default

44px
MemberRoleSessions
Ada Lovelace
ada@example.com
Administrator412
Grace Hopper
grace@example.com
Editor1,204
Alan Turing
alan@example.com
Viewer38

compact

36px
MemberRoleSessions
Ada Lovelace
ada@example.com
Administrator412
Grace Hopper
grace@example.com
Editor1,204
Alan Turing
alan@example.com
Viewer38
The same two-line cell on each step of the row ladder

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:

vue
<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:

PermissionAdminEditor
Sessions
View sessionsGrantedGranted
Delete sessionGrantedNot granted
A role per column, with the actions that act on the role
vue
<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 ​

  • label names the table; the component warns without one.
  • The primary column is each row's <th scope="row">; group headings are scope="colgroup".
  • Boolean cells carry their state as text as well as a disc — but only on the built-in path. The generic cell slot replaces that text along with the disc, so prefer cell:<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 a label of its own.
  • Cells truncate by default. Set wrap on a column whose text must stay readable — a truncated value with no title attribute is unrecoverable, so prefer wrap for anything long.

API ​

Props ​

PropTypeDefaultDescription
columnsHabDataColumn[][]—
density"default" | "comfortable" | "compact""default"—
emptyLabelstringundefinedShown 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.
groupsHabDataGroup[]undefinedGrouped rows. Wins over rows.
labelstringundefinedAccessible name for the table.
minWidthstring | numberundefinedWidth 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.
rowsHabDataRow[]undefinedFlat rows. Use groups instead when the rows band into labelled areas.
zebrabooleanfalseBanded rows. Leave it off unless the table is wide enough to lose your place — on a narrow table it is noise.

Events ​

EventPayloadDescription
rowClickpayload: { row: HabDataRow; index: number; }—

Slots ​

SlotBindingsDescription
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.
emptyanyShown instead of the rows when there are none.

HabDataCell ​

Props ​

PropTypeDefaultDescription
labelstring—The name.
notestring—Quiet second line — an email, a path, a kind.

Slots ​

SlotBindingsDescription
defaultanyThe name, in place of label.
leadinganyLeading HabAvatar or HabIconTile.

HabRowAction ​

Props ​

PropTypeDefaultDescription
disabledbooleanfalse—
iconstringundefinedSVG path data from @mdi/js. Defaults to the overflow dots.
labelstringundefinedAccessible name. An icon-only button needs one.

Slots ​

SlotBindingsDescription
leadinganyLeading adornment, replacing the glyph. Takes precedence over icon.

Proprietary — internal use only.