List
Grouped card: sections carry structure, no inset hairlines
Zebra (task 2763, experimental): opt-in alternating row tone
Full-bleed (plain): no card, edge-to-edge rules
Selectable · single · ↑/↓ to move, one cyan indicator
Selectable · multi · leading checkboxes
Sticky section header: scroll the region
Loading (skeleton) & empty affordance
Selected session r2 · 2 files checked · comfortable density. The container is live: change --color-divider and every hairline moves.
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,// not yet exported from @dizyx/nockerl-react; promotion is tracked.)// `List` is the CONTAINER; it arranges `NockerlListItem` rows (see the List item page).import { NockerlListItem } from '@dizyx/nockerl-react';
export function SessionList({ groups, activeId, onSelect, density }: SessionListProps) { return ( // variant: 'card' = lifted, inset grouped card · 'plain' = full-bleed (the dense tree) // selection: 'single' = listbox + arrow-key roving · 'multi' = leading checkboxes <List variant="card" density={density} selection="single" aria-label="Sessions"> {groups.map((g) => ( <ListSection key={g.key} label={g.label} count={g.items.length} sticky> {g.items.map((s, i) => ( <NockerlListItem key={s.id} primary={s.name} secondary={s.statusLine} status={s.status} // 'success' | 'warning' | 'error' | 'info' value={s.timeAgo} selected={s.id === activeId} onSelect={() => onSelect(s.id)} divider={i < g.items.length - 1} // inset hairline BETWEEN rows /> ))} </ListSection> ))} </List> );}// Android Jetpack Compose (canonical). The list container is a LazyColumn; the// row is NockerlListItemRow (see the List item page). Compose ratified TWO list// identities (Design Review #1, Decision 4): a DENSE flat tree and lifted cards.
// 1. Sectioned / grouped list: header item + row items per group, the TasksSheet// idiom. A LazyColumn with full-bleed rows; contentPadding sets the density.LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp), // dense tree spacing) { groups.forEach { (section, rows) -> stickyHeader(key = "h-${section.id}") { // sticky section header NockerlSectionHeader(label = section.title, count = rows.size) } itemsIndexed(rows, key = { _, r -> r.id }) { i, row -> NockerlListItemRow( leadingMark = { NockerlLeadingStatusMark(statusIcon(row.status), statusColor(row.status), row.status.name) }, modifier = Modifier.fillMaxWidth().clickable { onSelect(row) } .padding(horizontal = 16.dp, vertical = 12.dp), ) { Text(row.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) } if (i < rows.lastIndex) { HorizontalDivider(thickness = 1.dp, color = LocalNockerlColors.current.divider) // hairline BETWEEN rows } } }}
// 2. Lifted "card" list: spaced rows, depth on each card (the InboxList idiom).LazyColumn( contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp),) { items(notifications, key = { "n-${it.id}" }) { NotificationCard(it) } }// macOS SwiftUI (Voice). The GROUPED-CARD container: a scrolling LazyVStack of// rows wrapped in ONE elevated card (depth lives on the card, rows are flat), with// a SectionTitle header and a per-row bottom hairline. NockerlVoice/UI/HistoryView.swiftVStack(alignment: .leading, spacing: 14) { SectionTitle(title: "History") // the section header
ScrollView { LazyVStack(spacing: 0) { // rows, no inter-row gap ForEach(filtered) { record in row(record) // the inline row (see List item) .overlay(alignment: .bottom) { // hairline BETWEEN rows Rectangle().fill(NockerlTheme.hairline).frame(height: 1) } } } } .scrollIndicators(.never) // The container is the elevated card: surface + hairline border + lift. .background(NockerlTheme.chromeSurface, in: RoundedRectangle(cornerRadius: NockerlTheme.cardRadius)) .overlay(RoundedRectangle(cornerRadius: NockerlTheme.cardRadius).strokeBorder(NockerlTheme.hairline)) .nockerlElevation()}
// Multiple sections = stacked SectionTitle + card pairs in the parent ScrollView.// For a native, automatically-sectioned/inset-grouped list, `List { Section { … } }// .listStyle(.inset)` is the platform-native alternative (see drift note).Parameters
Section titled “Parameters”List is the container; ListSection groups rows under a header; the row itself is
ListItem. Selection, density, and the card-vs-plain surface
are list-level concerns set on List.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'card' | 'plain' | 'card' | Surface treatment. card = a lifted, inset grouped card (depth on the container; inset hairlines). plain = full-bleed, no card or shadow, edge-to-edge rules (the dense tree). |
density | 'comfortable' | 'dense' | 'comfortable' | Row height + padding only, never the fill. dense tightens spacing and drops the supporting line. |
selection | 'none' | 'single' | 'multi' | 'none' | List-level selection. single renders a listbox with arrow-key roving focus + the selected-row soft cyan wash + trailing check (never a stripe, Law 6); multi adds leading checkboxes. |
children * | ReactNode | ListSection groups or bare ListItem rows. | |
label (ListSection) * | string | Section header text, a muted uppercase label on the surface. | |
count (ListSection) | number | Optional trailing count shown beside the section label. | |
sticky (ListSection) | boolean | false | Pin the header to the top of a scroll region; keeps the surface token under it so rows never bleed through. |
divider (ListItem) | boolean | true | Render the hairline after the row, inset to the content column (clears the leading slot). Pass false on the last row of a group. |
There is no NockerlList wrapper component. The container IS a LazyColumn, configured
per the two ratified identities (Decision 4). These are the container-level knobs you set on it.
| Parameter | Type | Default | Description |
|---|---|---|---|
contentPadding | PaddingValues | PaddingValues(0.dp) | The list's outer padding. vertical = 4.dp for the dense tree; horizontal = 12.dp, vertical = 8.dp for the lifted-card list. |
verticalArrangement | Arrangement.Vertical | Arrangement.Top | Arrangement.spacedBy(6.dp) for the lifted "card" list (gap between cards); omitted (0) for the dense tree where a HorizontalDivider separates rows instead. |
item / items * | LazyListScope | Per-group emission: groups.forEach { item(header); items(rows) } builds section headers + rows in one tree (the TaskTreeList pattern). | |
stickyHeader | LazyListScope.() -> Unit | Pins a section header while its rows scroll. Intended for the section header; not yet adopted in-app (see drift). | |
HorizontalDivider | @Composable | The inter-row rule: thickness = 1.dp, color = colors.divider (MainScaffold). Cyan (1.5dp) is reserved for the chrome boundary, never a row divider. |
SwiftUI (Voice) has no extracted list component. The container is composed inline:
a ScrollView { LazyVStack(spacing: 0) { ForEach { row } } } wrapped in one elevated card,
with a SectionTitle header and a per-row bottom hairline. These are the building blocks.
| Style | Type | Default | Description |
|---|---|---|---|
ScrollView + LazyVStack(spacing: 0) | View | The scrollable list body. spacing: 0 so rows abut and the per-row hairline (not a gap) separates them. | |
SectionTitle | View | The section header, a bold title above the card. Multiple sections = stacked SectionTitle + card pairs in the parent ScrollView. | |
.background(chromeSurface, in: RoundedRectangle(cornerRadius: cardRadius)) | Modifier | The container surface (the grouped CARD). Depth lives here, not on the rows. | |
.overlay(strokeBorder(hairline)) + .nockerlElevation() | Modifier | The card's hairline border + lit-from-above elevation (neutral shadow + catch-light). | |
Rectangle().fill(hairline).frame(height: 1) | View | The inter-row hairline, applied as a bottom .overlay on each row. This is structure, never a per-row shadow. |