HabList
Rows of one kind of thing, inside one shared surface. A list, not a table.
- TranscriptionEnabled for 4 languages
- RedactionNames and addresses
- RetentionRecordings are deleted automatically90 daysPolicy
<HabList label="Workspace features" bordered>
<HabListItem
label="Transcription"
secondary="Enabled for 4 languages"
:icon="mdiCogOutline"
>
<template #trailing>
<HabSwitch
v-model="on"
aria-label="Transcription"
/>
</template>
</HabListItem>
</HabList>No column alignment, no header, no sort. Reach for HabDataTable the moment a second field needs to line up down the page — and for HabListRow when each row is a thing you open rather than operate.
HabListItem's leading slot is a bare icon or a small node, deliberately lighter than HabListRow's tile: these rows sit shoulder to shoulder, and a tile on each would stripe the panel.
Two forms
Slot children — HabListItem elements — for full control, which is what rows carrying their own controls need.
items for data-driven rows. Dragging requires this form: a row can only be moved if the list knows where it currently sits, and a slot child has no index to offer. Rather than guess at VNode order — which stops matching the DOM the moment the consumer reorders — the component warns and does nothing.
active is not selected
Both tint a row, and they answer different questions:
activeis where the user is — the current route or target. A neutral fill.selectedis what they have chosen — multi-select, a picker. An accent tint.
A row can be both, and selected wins, because it is the more specific claim about that row.
Density
default is 44px, compact is 36px, comfortable is 52px — the same ladder as HabAccordion and HabDataTable, so a list, a table and an accordion stack without the page reading as three different rhythms. Set it on the list; a single row can override it.
Reach for comfortable when the rows carry two lines — a name over a note. That content measures 35px, which sits in the 44px default with 4.5px of air, a third of what a one-line row gets. It is not a roomier-by-taste setting: a one-line row at comfortable just looks lost.
Dragging, and reordering
draggable turns on native HTML5 drag so a row can leave the list — a drop target elsewhere reads dragType off the dataTransfer, alongside a text/plain flavour so a target that does not know our type still receives something meaningful.
reorderable adds shuffling within the list, on top of that. Without it rows drag out but do not move.
The list reorders nothing itself. reorder reports { from, to } and the consumer applies it to their own array — the same shape as everything else here.
It works from the keyboard
Native HTML5 drag is pointer-only. The design contract's reorderable list therefore cannot be reordered without a mouse at all, which is why this one adds Alt+ArrowUp / Alt+ArrowDown on a focused row.
Alt because the rows are buttons and bare arrows belong to the browser's own caret movement; not Ctrl or Cmd because both are already page or window navigation.
Every move is announced in a live region. A reorder is otherwise invisible to a screen reader: the DOM changes and nothing says what happened, so the user has to re-read the list to find out whether their keypress did anything.
The grip only appears when reordering is on — a grip promises the row can be put in a new position, so a drag-out-only list must not show one.
Accessibility
labelnames the list. Without it the list is announced as a bare row count with nothing to say what it holds, so the component warns.- Rows are real
<li>elements inside a<ul>, so the list's length is announced. activesetsaria-current;selectedon an interactive row setsaria-pressed.- Not asserted in the specs: the pointer drag itself. happy-dom cannot produce a real drag sequence, and a fake one would assert nothing about what a user gets — see architecture.md. The keyboard path is asserted, which is the half that a test can honestly cover.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
bordered | boolean | false | Wraps the list in a card border. Use it when not already inside a HabPanel. |
density | "default" | "comfortable" | "compact" | "default" | — |
divided | boolean | true | Hairlines between rows. |
draggable | boolean | false | Turns on native HTML5 drag, so a row can be dragged out of the list onto a target elsewhere. Requires items. |
dragType | string | "application/x-habitus-list-item" | The dataTransfer type an external drop target reads. |
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 list renders a blank band. |
items | HabListEntry[] | undefined | Data-driven rows. Omit and pass HabListItem children for full control. Dragging needs this form. A row can only be moved if the list knows where it currently sits, and slot children have no index to give it. |
label | string | undefined | Accessible name for the list. A list of rows with no name is announced as a bare count. |
modelValue | string | undefined | The id of the selected row, matched against each entry's id. |
reorderable | boolean | false | Allows rows to be reordered within the list, on top of draggable. Without it rows drag out but do not shuffle. |
Events
| Event | Payload | Description |
|---|---|---|
dragItemStart | payload: { item: HabListEntry; index: number; event: DragEvent; } | — |
reorder | payload: { from: number; to: number; } | — |
update:modelValue | id: string | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
default | any | HabListItem children, for full control. Ignored when items is given. |
empty | any | Shown instead of the rows when there are none. Wins over emptyLabel. |
HabListItem
Props
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | false | The current route or target. A neutral fill. Distinct from selected: active is where the user is, selected is what they have chosen. A row can be both. |
density | "default" | "comfortable" | "compact" | undefined | Overrides the parent list's density for this row alone. |
disabled | boolean | false | — |
dragHandle | boolean | undefined | Shows the drag grip. The parent list sets this while reordering is on. |
icon | string | undefined | SVG path data (mdiPencil from @mdi/js), at the start of the row. |
interactive | boolean | false | Makes the row a real button. Omit for a static row. |
label | string | undefined | Primary row text. |
meta | string | undefined | Short right-aligned value before the trailing slot — count, size, date. |
secondary | string | undefined | Second line — path, description, timestamp. One line, truncated. |
selected | boolean | false | Chosen by the user — multi-select, a picker. An accent tint. |
Slots
| Slot | Bindings | Description |
|---|---|---|
default | any | Primary text, in place of label. |
leading | any | Anything at the start — a HabAvatar, a HabCheckbox, a color dot. Wins over icon. |
trailing | any | Anything at the end — an icon button, a HabSwitch, a HabChip, a chevron. |