# Collection Components

> Generated reference (regen-owned; see [index.md](index.md)). Binds only under `primitives: nurix`.

Components: Table, UiTable, List, Tree, FieldGrid, AnchoredScroller, Composer, MicInput, StreamingCursor, Marker

Each imports from its own subpath as one namespace, with its parts as dotted members, e.g. `import { UiTable } from "@nurix/components/block/ui-table"` then `UiTable.Row`, `UiTable.Cell`. `Table` is the exception: a single generic root whose cell renderers stay flat named exports.

## Table

Server-driven data table on a TanStack engine (`block/table`). Zero-config `<Table data columns />` renders bare; selection, sorting, pagination, resize and the grid keyboard model are opt-in via `tableConfig`. Install its two optional peer families first — `table` (`@tanstack/react-table`) and `spark` (`@mantine/charts @mantine/core recharts`, for the lazily loaded sparkline cell); a missing peer fails at the bundler with the package named.

**Props:**

- `data` - Array of row objects (each must have an `id` string)
- `columns` - Array of `ColumnDefinition` objects
- `columnOrder` - Composite column ids in display order (defaults to column definition order)
- `pagination` - `{ current, size, total, onChange }` — controlled and 1-indexed; slice `data` to the current page yourself
- `sorting` - `{ columns, direction, onChange }` (max 3 sort columns) — `columns` takes composite ids
- `onSelect` - Callback with selected row IDs
- `onCellChange` - Write-back from `editable` cells: `({ rowId, columnId, slug, value }) => void`; fires only on a real commit (a blur that typed nothing emits nothing), and the table never mutates `data`
- `loading` - Show loading skeleton
- `error` - `{ message, onRetry? }` — a failed fetch renders in place of the rows; wins over `loading`, which wins over the empty body
- `tableConfig` - Optional; `{ preset, rowSelection, sort, pagination, header, density, columnBorders, tableBorders, resize, autoHeight, gridAria }`

`density` is [compact|comfortable|spacious]; `columnBorders`/`tableBorders` are [none|all]. `preset: "classic"` sets a tinted short header row, comfortable density, the pagination footer and `tableBorders: "all"` in one key, and explicit keys override it. The pagination footer is opt-in (`pagination: true`); `gridAria` adds `role="grid"` with roving-tabindex arrow navigation.

**Composite ids.** `sorting.columns` and `columnOrder` take `getColumnId()`'s `${slug}_${component}` id — `name_text` for a `slug: "name"`, `type: "text"` column. A bare `"name"` silently matches no column.

```tsx
<Table
  data={pageRows}
  columns={columns}
  pagination={{ current: 1, size: 10, total: 100, onChange: setPagination }}
  sorting={{ columns: ["name_text"], direction: "asc", onChange: setSorting }}
  onSelect={setSelectedIds}
  tableConfig={{ rowSelection: true, pagination: true, resize: true }}
/>
```

## ColumnDefinition

Define table columns with the `defineColumn()` helper, which fills `component` from `type`, `hidden: false`, and empty `metadata`/`display`.

**Fields:**

- `slug` - Field key in the row object
- `label` - Column header text
- `type` - Data type: `text`, `number`, `boolean`, `date`, `enum`, `user`
- `component` - Renderer (defaults from `type`: `enum` → `select`, `user` → `assignee`, otherwise the type string): `text`, `number`, `boolean`, `date`, `date-range`, `select`, `multiselect`, `slider`, `rating`, `status`, `priority`, `assignee`, `sparkline`
- `hidden` - Hide column
- `editable` - Activate the interactive widget; columns render the read-only display variant by default
- `metadata` - Component-specific options (see table below)
- `display` - `{ width, sortable }` (width optional — columns are fluid when unset; `sortable` defaults to true except for `boolean` and `user`)
- `cell` - Custom render function `(row) => ReactNode`; wins over `component`

For full control, pass your own `cell()` and compose a renderer imported from `@nurix/components/block/table` — `EditableText`, `EditableNumber`, `EditableDate`, `EditableDateRange`, `BooleanCell`, `SliderCell`, `RatingCell`, `SelectCell`, `StatusCell`, `PriorityCell`, `AssigneeChip`, `MultiSelectCell`, `TableSparkline`. Each is a flat export so a consumer pays only for the renderers it imports.

### Metadata options by component

| Component | Options |
| --- | --- |
| Text/Number | `placeholder`, `prefix`, `suffix`, `prefixIcon` |
| Number/Slider | `min`, `max`, `step`, `stepButtons` [arrows \| plusminus \| none] |
| Enum/Select | `options: [{ value, label, color }]` |
| Date | `relativeFormat` |
| Date Range | `showPresets` |
| Rating | `allowHalf` |
| Sparkline | `sparklineType` [bar \| line \| pie \| stacked], `sparklineWidth`, `sparklineHeight`, `sparklineGap`, `sparklineStrokeWidth`, `sparklineColors` |

### Example

```tsx
import { defineColumn } from "@nurix/components/block/table";

const columns = [
  defineColumn<Row>({
    slug: "name",
    label: "Name",
    type: "text",
    editable: true,
  }),
  defineColumn<Row>({
    slug: "status",
    label: "Status",
    type: "enum",
    component: "status",
    metadata: {
      options: [{ value: "active", label: "Active", color: "#22c55e" }],
    },
  }),
  defineColumn<Row>({
    slug: "progress",
    label: "Progress",
    type: "number",
    component: "slider",
    metadata: { min: 0, max: 100 },
  }),
  defineColumn<Row>({
    slug: "createdAt",
    label: "Created",
    type: "date",
    metadata: { relativeFormat: true },
  }),
];
```

## UiTable

Bare native-table parts (`block/ui-table`) for markup you write yourself — no data model, no sort, no edit machinery. Reach for `Table` the moment the data needs sorting, editable cells or a `defineColumn` column set.

**Sub-components:** `UiTable.Header`, `UiTable.Body`, `UiTable.Footer`, `UiTable.Head`, `UiTable.Row`, `UiTable.Cell`, `UiTable.Caption`

- The root renders a `data-slot="table-container"` scroll wrapper around the `<table>`; `className` on `UiTable` styles the `<table>`, not the wrapper.
- Mark a selected row with `data-selected` on `UiTable.Row` (paints on the accent; `data-state="selected"` paints nothing). Body rows take the hover wash; header and footer rows stay still.
- No built-in loading, empty or error row — it renders exactly the rows it is given.

```tsx
<UiTable>
  <UiTable.Header>
    <UiTable.Row><UiTable.Head>Invoice</UiTable.Head></UiTable.Row>
  </UiTable.Header>
  <UiTable.Body>
    <UiTable.Row><UiTable.Cell>INV001</UiTable.Cell></UiTable.Row>
  </UiTable.Body>
</UiTable>
```

## List

Keyed collection whose rows you draw (`block/list`) — `item.id` is the React key, the selection key, the expand key and what row motion tracks. Rows with typed columns are `Table`; a chat transcript is `AnchoredScroller`. Activity feeds, record history, notification inboxes and card grids are `List` with a recipe.

**Sub-components:** `List.Toolbar`, `List.Pagination` (the parts List renders from `toolbar` and `pagination`), `List.ItemErrorBoundary` (wraps every row: a render throw becomes a compact error state while the other rows stand)

**Props:** `data`, `renderItem(item, index, { isExpanded, onToggle, view })`, `toolbar` (`{ search: { placeholder, keys }, filters: [{ key, label, options, multiple }], showExpandControls, actions }`), `pagination` (`{ current, size, total, onChange }` — when present, the parent has already filtered, sorted and paged), `expandedIds`/`defaultExpandedIds`/`onExpandedIdsChange` (`string[] | "all"`), `searchValue`/`defaultSearchValue`/`onSearchValueChange`, `filters`/`defaultFilters`/`onFiltersChange`, `onSelect`, `groupBy(item) → { key, label }` (a `Marker` separator before each run), `live` (holds rows arriving above the viewport behind an "N new" pill — keep `data` newest-first), `onLoadMore` + `hasMore` + `loadingMore`, `view` [list|card], `onItemActivate(id)`, `onItemError(error, id)`, `loading`, `error` (`{ message, onRetry? }`), `emptyState`, `labels`, `config` (`{ selection, density, dividers, animation: { type: slide|fade|scale|none }, expandable, pageSizeOptions, cardColumns }`)

- No virtualization: every item in `data` renders, so page an unbounded collection with `onLoadMore` or `pagination`.
- `expandedIds`, `searchValue` and `filters` go controlled the moment the prop is passed — `expandedIds={[]}` locks expansion closed unless `onExpandedIdsChange` drives it.

```tsx
<List
  data={events}
  renderItem={(event) => <FeedRow event={event} />}
  groupBy={(event) => ({ key: dayKey(event), label: dayLabel(event) })}
  toolbar={{ search: { keys: ["title"] }, filters: [{ key: "type", label: "Type", options }] }}
  onLoadMore={loadOlder}
  hasMore={hasMore}
  loadingMore={isLoading}
/>
```

## Tree

Hierarchy built from flat records that name their parent (`block/tree`); `useTree` is exported beside it for fully custom markup.

**Sub-components:** `Tree.Item`, `Tree.ItemLabel` (returned from `renderItem` when a row needs its own props or `render` element).

**Props:** `data`, `getParentId` (`null` for a top-level record), `isItemFolder`, `isItemDisabled`, `renderItem`, `selectionMode` [none|single|multiple], `selectedIds`/`defaultSelectedIds`/`onSelectedIdsChange`, `expandedIds`/`defaultExpandedIds` (an id list or `"all"`; default `[]`)/`onExpandedIdsChange`, `onItemActivate`

- Keep the accessor functions stable — the rows re-flatten whenever `data` or an accessor changes identity. Records whose parent id names no record never render.

```tsx
<Tree
  aria-label="Files"
  data={records}
  getParentId={getParentId}
  selectionMode="single"
  onItemActivate={openFile}
  renderItem={(item) => item.getItemData().name}
/>
```

## FieldGrid

One record's fields, column-driven and editable in place (`compound/field-grid`) — the description list. Skip it for one labelled form field (`compound/cell`) and for many records (`Table`).

**Props:** `columns` (`FieldColumn[]`), `data` (the one record), `onDataChange(slug, value)`, `arrangement` [grid|strip] (default `grid`, the responsive labelled `<dl>`; `strip` is the compact icon-and-value band), `columnsPerRow` (grid only, 1–9, default 6), `labels` (`{ empty, on, off }`)

**FieldColumn:** `id`, `slug`, `label`, `type` [text|number|boolean|date|slider|rating|enum|status|priority|assignee|avatar|avatarGroup|chip|timer], `options` (`[{ value, label, color, icon }]`), `readOnly`, `placeholder`, `min`/`max`/`step`, `format` (Intl number options), `colSpan`, `icon` (a Lucide icon, or `true` to pick one from `type` then `slug`), `validate(value)` (a returned message refuses the commit), `cell(value, record)` (wins over `type`)

- Editing is opt-in twice: a column is read-only unless it sets `readOnly: false` **and** the grid received `onDataChange`. Each editor commits once (Enter or blur for text, the pick for options, the release for a slider).
- The grid is naked — the strip's band chrome goes on the call site.

```tsx
<FieldGrid
  arrangement="strip"
  data={deal}
  onDataChange={(slug, value) => updateDeal(deal.id, { [slug]: value })}
  className="border-y bg-muted/30 px-5 py-3"
  columns={[
    { id: "stage", slug: "stage", label: "Stage", type: "status", options: stages, readOnly: false, icon: true },
    { id: "amount", slug: "amount", label: "Amount", type: "number", format: { style: "currency", currency: "USD" } },
  ]}
/>
```

## Page and record headers

No page-header block ships: compose the masthead at the call site. A title row and actions sit on a `Row`; a KPI ledger is several `Stat` (`element/stat`) on a `Row` beneath it, documented in [typography](typography.md); a record's labelled fields are `FieldGrid` with `arrangement="strip"`.

## Conversation surfaces

Chat and message-thread chrome; import each from its own subpath.

### AnchoredScroller

Scroll region that sticks to the newest message and offers a jump control once the reader scrolls away (`block/anchored-scroller`). Install its optional peer family `anchored-scroller` (`@shadcn/react`) first. A plain scrollable region with no stick-to-bottom behavior is `element/scroll-area`.

**Sub-components:** `AnchoredScroller.Provider`, `AnchoredScroller.Viewport`, `AnchoredScroller.Content`, `AnchoredScroller.Item`, `AnchoredScroller.Button`; hooks `useAnchoredScroller`, `useAnchoredScrollerScrollable`, `useAnchoredScrollerVisibility`

**Props:** Provider — `shouldAutoScroll`, `defaultScrollPosition`; root — `labels` (`{ region, scrollToEnd, scrollToStart }`); Viewport — `shouldPreserveScrollOnPrepend` (default `true`); Item — `itemId`, `isScrollAnchor`; Button — `direction` [start|end] (default `end`), plus `Button`'s `variant`, `size`, `isIconOnly`

- The hooks read the scroll store only from components rendered under `AnchoredScroller.Provider`.
- Give every Item an `itemId` — `scrollToItem`, `visibleItemIds` and `currentAnchorId` key off it.

```tsx
<AnchoredScroller.Provider shouldAutoScroll defaultScrollPosition="end">
  <AnchoredScroller labels={{ region: "Support thread" }}>
    <AnchoredScroller.Viewport>
      <AnchoredScroller.Content>
        {messages.map((m) => (
          <AnchoredScroller.Item key={m.id} itemId={m.id} isScrollAnchor={m.isTurnStart}>{renderMessage(m)}</AnchoredScroller.Item>
        ))}
      </AnchoredScroller.Content>
    </AnchoredScroller.Viewport>
    <AnchoredScroller.Button direction="end" />
  </AnchoredScroller>
</AnchoredScroller.Provider>
```

### Composer

Chat message composer: textarea, attachments, a footer of controls and a send (`@nurix/components/block/composer`, plus `useStagedFiles`, `composerNoticeVariants`).

**Props:** `onSubmit`, `onFilesDropped`, `reason` (blocks every send path and fills the Notice), `loading`, `accept`, `labels`

**Sub-components:** `Composer.Header`, `Composer.Attachments`, `Composer.Textarea` (`onEnterSubmit`, `maxRows`), `Composer.Footer` (`start`, `end`), `Composer.Attach` (`onSelect`, `multiple`), `Composer.Send`, `Composer.Notice` (`intent` [neutral|danger])

Enter submits (Shift+Enter is a newline) except while `reason` or `loading` holds the send. Render `Composer.Notice` unconditionally so a later reason announces. `useStagedFiles({ maxFiles })` returns `{ files, stage, remove, clear }`.

```tsx
<Composer onSubmit={send} onFilesDropped={stage} reason={closed ? "The window is closed." : undefined}>
  <Composer.Attachments>{files.map(renderChip)}</Composer.Attachments>
  <Composer.Textarea value={draft} onValueChange={setDraft} />
  <Composer.Footer start={<Composer.Attach onSelect={stage} />} end={<Composer.Send disabled={!draft.trim()} />} />
  <Composer.Notice />
</Composer>
```

### MicInput

Microphone toggle with a live level meter, as a latch over the caller's engine or over a built-in recorder (`@nurix/components/compound/mic-input`, plus `useMicInputState`, `useMicRecorder`; recorder mode needs the optional peer `react-voice-visualizer`).

**Props:** `pressed`, `onPressedChange`, `onValueCommitted` (latch mode) or `recorder` (from `useMicRecorder()`), `status` [idle|listening|processing|error], `waveform` [true|false|"always"], `onError`, `labels`

**Sub-components:** `MicInput.Trigger` (`size` [sm|default|lg]), `MicInput.Meter` (`variant` [static|history], `deviceId`)

A denied microphone surfaces only through `onError` (meter) or `recorder.error`. A composer's voice button is a `Button` in `Composer.Footer`.

```tsx
<MicInput pressed={isRecording} onPressedChange={toggle} status={status}>
  <MicInput.Meter variant="history" />
  <MicInput.Trigger size="sm" />
</MicInput>
```

### StreamingCursor

The blinking caret at the tail of a live stream (`effect/streaming-cursor`); inline, `h-[1lh]`, `aria-hidden`, blink shipped in the library stylesheet. Change the element with `render` (no `as` or `asChild`). Before the first token, show `element/loader`; inside a code block, set `compound/code-block`'s `isStreaming` prop instead.

```tsx
<p>{streamedText}{isStreaming && <StreamingCursor className="ml-0.5" />}</p>
```

### Marker

Muted full-width row for out-of-band thread content — a date divider or a system event (`element/marker`). It is not a notification badge; an unread dot on an avatar is `Avatar.Badge`.

**Sub-components:** `Marker.Icon` (`aria-hidden` — put any meaning in the content text), `Marker.Content`; also `markerVariants`

**Props:** `variant` [default|separator|border] (`separator` flanks centered content with hairlines; `border` underlines the row), `render` (non-interactive elements only — an action goes inside `Marker.Content` as a `Link` or `Button`)

```tsx
<Marker variant="separator">
  <Marker.Content>Today</Marker.Content>
</Marker>
```

## Fallback-only concepts

No nurix primitive — build each from its plain-spec fallback in [index.md](index.md):

- `gantt-timeline`
- `chat-bubble`
- `json-viewer`
- `virtualized-list`
- `kanban-board`
- `event-calendar`
- `filter-toolbar`
- `vertical-timeline`
