EmList
A single-select list of structured rows — media, content and meta parts on a real button, with the selection flowing through v-model.
Usage
EmList renders a <ul> and owns the selection; each EmListItem is an <li> wrapping a row host — a native <button> by default — registered under its value. Clicking a row selects it and writes its value to v-model; clicking the selected row again deselects it, unless mandatory says otherwise.
The parts inside a row are free-form. EmListItemMedia, EmListItemContent with its EmListItemTitle and EmListItemSubtitle, and EmListItemMeta are presentational spans that give a row the classic leading-graphic / text-block / trailing-detail shape, and any of them can be omitted or reordered.
Anatomy
<script setup lang="ts">
import {
EmList,
EmListItem,
EmListItemContent,
EmListItemMedia,
EmListItemMeta,
EmListItemSubtitle,
EmListItemTitle,
} from '@paper/emerald'
</script>
<template>
<EmList>
<EmListItem>
<EmListItemMedia />
<EmListItemContent>
<EmListItemTitle />
<EmListItemSubtitle />
</EmListItemContent>
<EmListItemMeta />
</EmListItem>
</EmList>
</template>Composed on v0
EmList renders v0’s Single compound. Single.Root and Single.Item are both renderless, so the elements are all Emerald’s: the root provides the selection context around a real <ul>, and each item resolves its state inside an <li> and binds it onto the row host. The split is clean — v0 owns registration, the exclusive-selection rule, mandatory enforcement and the disabled resolution (a row is disabled when either it or the list is); Emerald owns the markup, the data attributes and every pixel.
One deliberate deviation is worth knowing. Single.Item offers listbox-flavored attrs — role="option", aria-selected, a tabindex — and EmListItem binds only the click handler and the state attributes, not the role. The list is not a listbox: there is no roving focus and no typeahead, and a bare option outside a listbox misleads assistive technology worse than an honest button. Each row is a plain button that marks its selection with aria-current instead.
The underlying logic, if you want it without the styling, is createSingle.
Examples
Props
EmList
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | T | — | The selected row’s value; undefined when nothing is selected |
mandatory | boolean | 'force' | false | true keeps the last selection from being toggled off; 'force' additionally auto-selects the first non-disabled row |
disabled | boolean | false | Disables the whole list. Every row resolves as disabled, and the <ul> gets data-disabled |
id | string | — | Forwarded to the <ul> |
namespace | string | — | Which v0 Single instance to provide. Only needed when nesting lists |
The default slot is the rows. There are no named slots.
EmListItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | ID | required | Registration value; what v-model becomes when the row is selected |
disabled | boolean | false | Disables this row. A button host also gets the native disabled attribute |
as | string | 'button' | Row host element. Anything other than button renders a non-interactive row — no click wiring, no native disabled |
namespace | string | — | Which v0 Single instance to bind to. Only needed when nesting |
Attributes bind to the row host, not the <li> — class, data-unread and friends land on the element the stylesheet targets. The host publishes data-selected and data-disabled for styling, plus aria-current when selected.
Parts
EmListItemMedia, EmListItemContent, EmListItemTitle, EmListItemSubtitle and EmListItemMeta take no props — each renders a styled <span> around its default slot. Title and subtitle truncate to a single line; media and meta refuse to shrink; content flexes and stacks its children.
Accessibility
The default row is a native <button type="button">, so focusability, the implicit button role, and activation by Enter and Space all come from the platform.
Not a listbox
EmList deliberately ships no role="listbox" and no role="option": there is no roving focus and no typeahead, and option semantics without a managed listbox around them promise keyboard behavior that does not exist. The honest shape is a <ul> of buttons — which is what this is.
The practical consequence is that every row is its own tab stop and there is no arrow-key navigation; Tab and Shift+Tab walk the rows. That is fine for the short-to-moderate lists this component is for. A very long list is better paired with a filter above it than navigated by key, and a true keyboard-operated selection surface (a combobox’s panel, a select menu) belongs to components that implement the full pattern, like EmSelect.
Selection state
The selected row carries aria-current="true" — “the current item within a set” — alongside the data-selected styling hook. aria-selected is not used, because it belongs to the listbox/tab/grid roles this list intentionally does not claim.
Disabled rows
A disabled button row gets the native disabled attribute: activation is blocked and the row leaves the tab order, so a keyboard user tabbing the list never encounters it. Disabling the whole list cascades — every button row is disabled natively, not just dimmed. Non-button hosts get only the data-disabled styling attribute; whatever controls you place inside them are yours to disable.
Naming
A row’s accessible name is the concatenated text of everything inside it — title, subtitle and meta together, in order. That is usually right for a structured row (“Sponsorship renewal, The invoice for the next quarter…, 09:41” reads like the row looks), but it means the meta part is part of the name: keep it short and meaningful, and keep purely decorative content inside the media part, where an icon or avatar contributes nothing to the name unless you label it.