Skip to content

feat(DST-1665): add selection to ListView - #5777

Open
sarahgm wants to merge 36 commits into
mainfrom
feat/DST-1665-listview-selection
Open

feat(DST-1665): add selection to ListView#5777
sarahgm wants to merge 36 commits into
mainfrom
feat/DST-1665-listview-selection

Conversation

@sarahgm

@sarahgm sarahgm commented Sep 1, 2026

Copy link
Copy Markdown
Member

Description

<ListView> shipped with selection deliberately omitted: all six selection props were stripped from its public type and selectionMode was hardcoded to "none". This adds it.

Current behaviour: rows are operated where they sit, nothing can be marked.
New behaviour: selectionMode="single" or "multiple" makes rows selectable, defaulting to "none" so a list that does not ask for selection is unchanged.

The selection is view state, not a field value: read it through onSelectionChange, hold it yourself, commit it when you decide. No FieldBase, no hidden input, no name/form/validate. That split is what keeps two GridList wrappers from being redundant now that both render a nearly identical stack of rows, and it is the question both docs pages now lead with: does the selection need to submit with the form?

Signed off by Sebastian Sebald on 2026-08-04 in the ticket, with the two conditions carried through.

Closes DST-1665

What is in here

  • Selection API. selectionMode, selectedKeys, defaultSelectedKeys, onSelectionChange, disallowEmptySelection un-omitted. selectionBehavior stays hidden and fixed to "toggle", matching <Table>. onSelectionChange carries React Aria's raw Selection ('all' | Set<Key>), deliberately not <SelectList>'s mode-typed onChange.
  • Row layout. The named-area grid gains a leading indicator region; the ListView slot union in @marigold/system gains indicator to match.
  • Shared indicator. SelectList/SelectionIndicator.tsxutils/GridSelectionIndicator.tsx, used by both wrappers. Nothing was publicly exported, so this is internal naming only.
  • Docs. New Selection section with three demos, indicator in the anatomy, the decision test on both component pages, and the Pick pattern's dialog pick migrated from <SelectList> to <ListView>.
  • Three changesets. The feature, plus one each for the two visual changes below, so a consumer meets them in the changelog instead of discovering them.

Not in here

Select-all. The Bulk Actions pattern puts it in a header checkbox and a list has no header row to hold one. A multi-select list still composes with <ActionBar> (driven by useActionBar, which measures the bar so you can reserve its height), but a flow where users select every visible record still wants a <Table>. Filed as DST-1751.

Highlight-style selection. No equivalent of Spectrum's selectionStyle="highlight", so no press replaces the whole selection and Shift+click takes no range. <Table> is the same, so this is system-wide.

In here, but outside the ticket's scope

DST-1665 lists three of the things in this PR under Not included, one of them as a decision it had already made the other way. All three are deliberate and two carry their own changeset, but "separate ticket" was the ticket's call, so reversing it is a reviewer's rather than the diff's.

1. <SelectList>'s indicator alignment. The ticket scoped <ListView>'s centred indicator as a deliberate divergence, and put bringing <SelectList> into line in a separate ticket, being "a visible change to a merged component". It went further and pre-committed to "a source comment naming SelectList.styles.ts and this decision".

Shipped here instead, with its own changeset and its own row in the visual-changes table below. The alternative was shipping a divergence plus a ticket to undo it, plus a source comment explaining a mismatch nobody had a reason to want. This is the one that reverses a settled decision rather than filling a gap, so it is the one most worth pushing back on.

2. The Pick pattern's people pick. The ticket put migrating the Pick demos onto the selectable <ListView> in a follow-up depending on this one. The people pick moves here, because a dialog that stages a selection and commits it with its own button is the exact case the field-versus-view split was drawn for, and leaving it on <SelectList> ships docs that argue for a rule and a demo that breaks it. It touches pick-list.demo.tsx and the "List or table" section, which DST-1651 is also working in.

3. <ActionBar>. The ticket put bulk-action bar wiring in a separate follow-up. No wiring was added. <ActionBar> already takes selectedItemCount and onClearSelection and only falls back to the context a <Table> publishes, so what is here is a story, a demo and a docs section. This one reads as demonstrating a composition that already worked rather than building it.

Screenshots / Preview

Three visual changes for review, two of them in components this ticket was not about. Please look rather than bulk-accepting.

Change Where
Indicator column added to the row every selectable <ListView>
Indicator drops ~8px on description-bearing rows <SelectList>, all variants
Row focus ring moves from outward outline to inset ring every focused <ListView> row

Two things I could not judge from source and would like a second opinion on:

  1. The people pick at /patterns/user-input/pick now uses <ListView>, which draws no frame where <SelectList> drew a boxed surface. Does it read as intentional inside the dialog, or unmoored?
  2. The bulk-actions demo uses a max-h-64 scroll container so the sticky bar has something to pin to. Does the reserved padding at the bottom look deliberate or like a gap?

Test Instructions

  1. pnpm sbComponents/ListView. Use the new selectionMode dropdown (none / single / multiple) on any story.
  2. SelectionWithRowActivation: press a row with nothing selected (it opens), tick a checkbox, press another row (it marks instead). Press Escape, then press a row again.
  3. WithActionBar: select rows, check the count, clear it.
  4. SelectionWithMixedRowContent: confirm the indicators of both rows line up despite the badge.
  5. pnpm start/components/collection/listview, /components/form/selectlist, /patterns/user-input/pick.
  6. pnpm test:sb and pnpm test:unit.

Breaking Changes

No. selectionMode defaults to "none", so existing lists are unchanged. The two visual changes are patch-level and carry their own changesets.

Three findings worth a reviewer's attention

1. React Aria: Enter is inert while a selection exists. With onAction set alongside selectionMode, the gestures split by whether anything is selected. Measured, not inferred:

Selection Click a row Enter Space Escape
Empty opens opens marks
Not empty marks nothing at all marks clears

A click at least marks the row. Enter fires no action and does not toggle, so a keyboard user gets no response and no hint why. React Aria's selection guide documents only "clicking or tapping a row toggles the selection" and says nothing about Enter going dead, so keyboard and pointer diverging here looks like an oversight rather than a decision. It is arguably a 2.1.1 concern: the open action is reachable by mouse in one state and by keyboard only in the other.

Pinned by a story test (Enter opens only while the selection is empty) and named in the ListView docs, which tell you to keep a clear button in reach rather than relying on Escape. No ticket filed yet — worth deciding whether we report it upstream, work around it, or accept it.

2. The decision test now lives on two pages with nothing checking they agree. It drifted once within a day of being written, which is why the SelectList page now keeps a one-sentence answer and cross-links to the full comparison rather than repeating it. There is no automated guard.

3. The <SelectList> indicator converged on one axis, not two. row-span-2 lines the indicator up with the action slot beside it, which already centred against the whole text stack, so vertically the two wrappers now follow one rule. Horizontally they still differ:

Horizontal placement in the indicator column
<ListView> justify-self-start, pinned to the column start
<SelectList> stretches, justify-center, centred in the column

This is only observable once the indicator column widens, which happens when a child claims no cell and auto-places into it. <ListView> documents that case (a stray <Badge>) and pins the indicator so the widening cannot drag it. <SelectList> has no equivalent documented case, so the divergence is latent rather than live. Left alone deliberately, to keep this PR to one visual change in a merged component rather than two.

The selection changeset says centring is the rule "both wrappers now follow, so the two cannot be told apart on this point". That holds for the vertical axis and overstates the horizontal one. Flagged here rather than edited, so the changelog keeps one sentence instead of a caveat.

Blast radius of the indicator move, since the changeset gives only the headline number:

  • Options with no <Description> do not move. The grid is grid-rows-[minmax(1.25rem,auto)_auto], so the second row collapses to 0 and centring over one row or two lands on the same pixel.
  • The bordered "Options as cards" demo does not move either. It authors its content as a single unslotted <Stack> that auto-places into row 1 and never uses <Description>, so row 2 stays empty. The tallest option shape in the system is untouched.
  • What moves is label-plus-description options, by roughly 8px.

One latent risk: a description that wraps to several lines drops the indicator further from the label it belongs to. Nothing in the repo does that today and the docs ask for a short description. If it comes up, the fix is self-start past a threshold rather than reverting to row-start-1, which would reintroduce the mismatch with action.

Checklist

  • Storybook preview and Marigold docs preview are available
  • Stories added/updated (with component-test tag where applicable)
  • Unit tests added/updated
  • Component documentation added/updated (if it exists)
  • Accessibility reviewed against ARIA APG (for new/changed interactive components)
  • Visual regression tests updated (for UI changes) — needs /run-chromatic, see the three diffs above
  • Changeset added (pnpm changeset)

🤖 Generated with Claude Code

sarahgm added 25 commits August 31, 2026 10:37
…ation

`SelectList/SelectionIndicator` moves to `utils/GridSelectionIndicator` so
`ListView` can consume it rather than fork it. Both consumers are
`GridListItem`-backed rows, which is what distinguishes this indicator from
`ListBox/SelectionIndicator` (a `contents` wrapper injecting a hidden `<Check>`)
and from RAC's own, already imported by `SegmentedControl`. None of the three is
publicly exported, so the rename is internal only.

`selectionMode` widens to `@react-types/shared`'s `SelectionMode` and `'none'`
now returns nothing, which `ListView` needs because it defaults to `'none'`.
Returning `null` rather than an empty wrapper leaves the row's `auto` indicator
track at zero width. `SelectList` can't reach that branch.

No behaviour change to `SelectList`: same branches for `'single'` and
`'multiple'`. Widening the prop type also removes the `as 'single' | 'multiple'`
cast at the call site, which only existed because the type was narrower than the
render prop it was fed from.
The row template gains an `indicator` column and area name, listed in both
template rows so it spans them without a span count, the way `actions` already
does. Adding a column is the only edit named areas need; nothing else in the
template moves.

Spacing rides on the cell (`me-3`) rather than a column gap, mirroring
`actions`' `ms-3`. With no element in the area the `auto` track sizes to zero and
the margin never applies, so a list without a `selectionMode` renders exactly as
before.

Also adds the `selected:` fill and the row cursor. The `not-disabled:` prefix on
the cursor is required rather than tidiness: ungated,
`data-selection-mode:cursor-pointer` has equal specificity to
`disabled:cursor-not-allowed` and wins on source order, so a disabled row would
show a pointer.
…xt stack

The indicator aligned to the label row while the `action` slot beside it spanned
both rows, so on an option carrying a description the two disagreed by roughly
8px. Both now span the stack, which is one rule for every non-text region rather
than two.

Done here rather than in a follow-up because `ListView` adds the same indicator
in the same change: deferring would ship a divergence, a source comment
explaining it, and a ticket to undo both. Visible only on options with a
description; single-line options render identically.
`selectedKeys`, `defaultSelectedKeys`, `onSelectionChange` and
`disallowEmptySelection` pass through from RAC untouched. `selectionMode` is
redeclared to carry a doc comment and default to `'none'`, so selection is
opt-in and no shipped list changes. `selectionBehavior` stays omitted and
hardcoded to `'toggle'`, which is what lets a row carry focusable controls.

No default for `disallowEmptySelection`, deliberately diverging from
`SelectList`'s `true`-in-single: a view's selection is transient state the user
must be able to abandon, and `true` also disables Escape-to-clear.

`ListView.Item` moves to the render-prop form to reach `selectionMode` /
`isSelected` / `isDisabled`, the same shape `SelectList.Option` uses.
`ItemChildren` stays a separate component because the stacked-actions warning
depends on running inside the row on RAC's second render pass.

Adds `SingleSelection` and `MultipleSelection` stories. These are load-bearing
rather than decoration: a bare `<ListView>` throws without a theme provider, so
a story is the only way to exercise the new modes.

Also trims this file's comments, which the header change forced anyway: it
claimed every region claims its cell through a slot context, and the indicator
does not.
Covers the ARIA shape (`aria-multiselectable` in multiple mode only,
`aria-selected` present only when selectable, one gridcell per row) and the
keyboard contract (arrow keys move focus without changing selection, Space
selects, Escape clears). Adds `SelectionWithRowActions` to prove the GridList
advantage over ListBox: pressing a row's `ActionMenu` opens it without toggling
the row.

The indicator now publishes `data-grid-area="indicator"`, so it is assertable
like every other region instead of only reachable through a className. This also
makes `SelectList`'s indicator assertable, which it was not.

Two notes for review. The row checkbox announces as RAC's generic "Select"
rather than taking its name from the row; identity lives on the row's own
`aria-label` plus `aria-selected`. And selection announcements come from RAC's
live region, which is relied on rather than asserted here.

Running axe with `a11y.test: 'error'` shows the row description failing AA on a
selected row (3.60:1, needs 4.5:1). That is a pre-existing system-wide token
problem, not a selection one: `text-secondary` at 12px already fails on hover
(4.35:1) in the shipped component, and ten theme files pair the two. Hover was
never caught because axe only tests static states. Filed separately.
Four stories, all `component-test` tagged:

- `SelectionWithMixedRowContent` is the regression story the review asked for:
  a selection column, two trailing controls in a `<ButtonGroup>`, and a
  non-Button child. It asserts every named region keeps its cell and that the
  badge, which claims nothing, cannot take one.
- `SelectAll` shows the recipe rather than an API — a `<Checkbox>` above the
  list driving `onSelectionChange`, reading mixed while partial.
- `SelectionWithRowActivation` pins RAC's `onAction` mode switch in both
  directions: a row press opens while nothing is selected, and toggles once
  something is.
- `SelectableDisabledRows` exercises `disabledBehavior="selection"`, which had
  no observable effect before this ticket.

Two findings worth keeping. A native checkbox carries its mixed state on the DOM
`indeterminate` property, not as `aria-checked`, so the matcher is
`toBePartiallyChecked`. And RAC drops `aria-selected` entirely on a
selection-disabled row rather than reporting `false`, which is the right call: a
row that cannot be selected should not advertise a selection state.

Also corrects the `disabledBehavior` argType, which still claimed ListView has
no selection, and renames a test that described the component where it meant the
default.

The two placement assertions added in edd05f6 moved here from the unit tests:
`testing-library/no-node-access` forbids `querySelector` in `*.test.tsx`, and
structure is the story tests' job anyway.
The indicator slot carried `justify-center`, copied from `SelectList`. Anything
that widens the indicator column then centres the checkbox inside the wider box,
so checkboxes drift out of line row to row. Measured with a `<Badge>` authored as
a sibling of `<TextValue>`: the column went 24px to 64px and the checkbox moved
14px right of the row below it.

`justify-self-start` also content-sizes the wrapper, so the mark sits at the
column's start edge whatever else the row carries. Pinned by a story test
comparing both rows' indicator `x`.

Introduced in 5237c7a, so this is a fix within the ticket rather than a
pre-existing defect.
Four changes, all from reviewing the stories rather than the code.

`SelectAll` becomes `WithActionBar`. The select-all recipe was a bare checkbox
above the list, and no placement fixed it: Marigold's Bulk Actions pattern puts
select-all in the collection's header checkbox and the count in the ActionBar,
and `ListView` has no header region. `<ActionBar>` needs no new API though — it
reads `selectedItemCount` / `onClearSelection` from props and only falls back to
the context `Table` publishes. So the story shows selection revealing the bar,
with nothing at rest. Select-all is DST-1751.

`SelectableDisabledRows` is gone, its assertion moved into the unit tests. With
`disabledBehavior="selection"` RAC sets neither `data-disabled` nor
`aria-disabled`, so every disabled style is inert and the row gives no reason it
cannot be selected. Worth pinning as behaviour, not publishing as an example.
The argType now says so.

The `<Badge>` moves inside `<TextValue>`. As a sibling it auto-placed into an
implicit third row in the indicator column, widening it and pushing that row's
label out of line with the whole list. Nesting unslotted content inside a slotted
parent is the rule the docs already state for `<Text>`. A test now compares both
rows' indicator `x` so the alignment cannot regress.

`selectionMode` gains a select control, and the six stories that hardcoded it
after the args spread now set it through `args` instead, so the control is live
rather than silently overridden.
Adds the Selection section the feature needs: the two modes and the mark
each one shows, that the selection is view state the consumer commits,
the dual gesture when `onAction` is set alongside it, and the ActionBar
composition. Two demos carry it, one per mode.

Also corrects three things the change makes wrong or incomplete:

- "A `<ListView>` also has no selection" is gone, replaced by the line
  that still holds: the selection never submits.
- The anatomy gains the indicator, and the row text moves right to clear
  its column. The label points at row 2, where there is room for it, and
  the viewBox stays at the width `docs/CLAUDE.md` pins.
- The `<Badge>` guidance moves from "sits in the trailing cell while that
  cell is empty" to "nest it in the row's text". A selectable row makes
  the old advice actively harmful: an unslotted child lands in the
  indicator column and pushes every row's text out of line.
ADR-0006 requires the same decision test on both component pages, because
a consumer who lands on `SelectList` first never sees a test written only
on `ListView`. The test is one question, "does the selection need to
submit with the form?", with the field-versus-view rationale underneath
as explanation rather than as the decision aid.

Two more pages pointed somewhere that is now wrong:

- Bulk Actions is written around a `<Table>`, and stays that way, because
  select-all needs a header checkbox. It now says what a `<ListView>` can
  and cannot carry instead of leaving the reader to infer it.
- ActionBar's "Outside a table" list names `<ListView>`, since a
  selectable list is the case that section describes best.
The section led with `selectionMode` and its two values, which answers
"how" to a reader who has not yet decided "whether". It now opens with
the two situations that earn selection, names the mode each one wants,
and says to leave it off otherwise.

Two paragraphs that went too deep for a usage section are gone with it:

- The mark each mode shows is already an Anatomy bullet, and why the
  radio circle stays out of the accessibility tree is already under
  Accessibility.
- `onAction` firing or staying quiet is restated as what the user sees,
  the row opening or being marked.
Two bullets describe a behaviour that changes under the reader's hands,
which is exactly the case a demo carries better than prose. The line
under the list names which gesture is live right now, so pressing a row
before and after checking one shows the switch rather than asserting it.
It opened by asserting the two are easy to confuse and went straight into
the question. Now it starts from what the reader has already noticed, that
the two look alike on screen, names the axis that separates them, and
only then puts the question.
The demo only printed a line claiming a file had opened, which asks the
reader to trust a label while nothing on screen changes. A row press now
leaves the list for the file's detail view, with a way back, so opening
is something you watch happen and the second gesture reads as the
contrast it is.

Also corrects the paragraph under it. It said users know this from their
file manager, which describes React Aria's `replace` behaviour: click
selects, double click acts. Marigold hardcodes `toggle`, where the action
fires only while nothing is selected. The paragraph now states the
consequence that needs designing around, that opening is unreachable
while a selection exists, and names Escape as the way back.
The detail view was more demo than the point needed: a second screen, a
headline, a back button and an icon, for a section about which gesture a
press performs. It is now the list, `onAction`, and an alert, the same
idiom the other demos on the page use to show that something ran. The
prose above already explains the two states, so the demo does not narrate
them as well.
…urce

ADR-0006 requires every deliberate divergence between the two wrappers to
be commented where it lives, so it cannot later read as drift. The
`disallowEmptySelection` default was the one divergence and the only
uncommented one.
Measured rather than inferred, because the React Aria guide only covers
clicking. With a selection present: Enter does nothing at all, not even
what a click would do, Space marks the focused row, and Escape empties
the selection so Enter opens again.

The docs said "every row press marks a row instead", which is true of a
click and wrong about Enter. They now name the dead key, since a keyboard
user pressing Enter mid-selection gets no response of any kind, and say
to keep a clear button in reach instead of relying on Escape.
The comment cited a record that is no longer in the repo. The reason it
gives stands on its own, so it keeps the reason and loses the pointer.
Only the ListView copy got smoothed, so the shared parts had already
drifted a day after being written. The opening and the rationale are now
the same words on both pages, which they can be because both are
symmetric about the two components.

The closing paragraph stays page-specific on purpose. A reader on the
SelectList page needs the warning about a dialog pick that never submits,
which is where the wrong choice actually gets made; a reader on the
ListView page needs to be told not to use "field versus view" as the
decision aid.
Two copies of the same four paragraphs had already drifted once inside a
day, which is the argument against keeping them. The ListView page owns
the full comparison; this page keeps the answer inline, one sentence
either way, so a reader still learns which component to use without
following a link, then points at it for the reasoning.

The dialog-pick warning stays here rather than moving with the rest. It
is the mistake made on this page, by a reader who has already decided
`<SelectList>` is the component, and it is the one thing the ListView
page has no reason to say.
The decision test asks whether the selection submits with the form, which
turns out to be necessary but not sufficient. Checking the Pick pattern
found a pick with no `<form>` anywhere that still has to stay a
`<SelectList>`: it carries a visible label, helper text, and a
minimum-one rule, and `ListView` has no `label`, `description`,
`errorMessage`, `error` or `required` prop to render any of that.

Both pages now name the exception, so the test cannot send a labelled
pick to a component that would silently drop its label.
"List or table" recommended a `<SelectList>` for any pick lighter than a
table, which since this branch is wrong for half of them. It now chooses
by surface: staged in a dialog and committed by its button is a
`<ListView>`, a labelled field on the page stays a `<SelectList>`. It also
says which of the page's own two demos is which, so the pair reads as a
deliberate contrast rather than an inconsistency.

The people pick migrates with it. `onChange(Key[])` becomes
`onSelectionChange(Selection)`, which is not a rename: `Selection` can be
the string `'all'`, so the off-view merge resolves that to the visible ids
first. Without it a select-all would drop the staged rows that filtering
had hidden, which is the bug the merge exists to prevent.

The Abonnement pick is deliberately left as a `<SelectList>`. It is the
labelled-field case from the exception above.
The plan asked for the scroll-padding caveat and I had written it as
prose, describing a failure the demo could not show: the demo put the bar
below a three-row list, which is the one arrangement that needs nothing.

Checking it turned up the fact that changes the advice. `<ActionBar>`
carries `sticky bottom-(--actionbar-offset)` in the component, not the
theme, so it pins itself to whatever scroll container it is rendered
inside. Nothing about that is Table-specific, and `useActionBar` is
exported, so the composition a real consumer needs is a few lines rather
than a warning.

The demo now scrolls six rows, drives the bar with `useActionBar`, and
reserves the measured height in both `padding-bottom` and
`scroll-padding-bottom`. The paragraph moved ahead of it and says why
that measurement is needed. One line after it keeps the simpler path for
a list too short to scroll.
Three rather than the two the plan called for. The plan was written before
the focus-ring change existed, and its argument for splitting the
alignment fix out applies to that one too, more strongly: a consumer
asking why their focus rings look different should find a line about
focus rings, not have to read a selection feature entry to reach it.

- feature: system/components/theme-rui minor, docs patch
- SelectList indicator alignment: theme-rui patch
- ListView row focus ring: theme-rui patch

`@marigold/system` is listed because the `ListView` slot union gained
`indicator`, which is a real change for anyone writing a custom theme.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marigold-docs Ready Ready Preview Sep 4, 2026 2:05pm UTC
marigold-storybook Ready Ready Preview Sep 4, 2026 2:05pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
marigold-production Ignored Ignored Sep 4, 2026 2:05pm UTC

Request Review

@github-actions github-actions Bot added type:docs Improvements or additions to documentation type:feature New feature or component labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

`Theme['components']['ListView']` gained `indicator`, and the manifest is
what `stub-missing-slots` reads to generate a consumer's missing slots.
Left out, a consumer with their own ListView theme gets a type error on
upgrade and no stub to fix it with, since the slot union is a `Record` and
every key is required.

`theme-drift.test.ts` caught it, which is the job it exists for.
…stories

`findByRole` waits for presence, not visibility, so it resolved while the
bar was still at `animate-slide-in`'s `opacity: 0` frame and `toBeVisible`
counted it hidden. Green locally, red on the slower CI runner. Wrapped in
`waitFor`, which is what every other ActionBar assertion in the repo does.

The unit tests' selection block also built its own row fixture, provider
wrapper and `Record<string, unknown>` prop bag while this branch was adding
stories that render exactly that. It renders `MultipleSelection.Component`
now, per CLAUDE.md, and the render-unmount-render test is two tests.

Drops `onVenueSelectionChange`, a module-scope spy every test overrode, and
takes `Selection` from `../types` like `Table.stories.tsx`.
`GridSelectionIndicator` stamped `data-grid-area="indicator"` itself, so
every SelectList option carried an attribute naming a cell its grid has no
named areas for. It takes a `gridArea` now and `ListViewItem` passes it,
matching how label/title/description/actions already work: whoever supplies
the positioning className supplies the attribute.

`ListView`'s context memo could never hit, because `useClassNames` returns a
fresh object every render. It memoises on the slot strings instead, which
matters now that a selection change re-renders the list on every click.

Two one-line rationales back in the theme: why the row has no column gap,
and why the focus ring is inlined rather than `ui-state-focus-item`.
The selection demo invented a fourth venue dataset while `@/lib/data/venues`
is what `table-selection` and `selectlist-selectable-cards` both draw from.
It takes the same `slice(2, 5)`, so the three components the page tells you
to compare now show the same three records.

`pick-select-list.demo.tsx` contains no SelectList any more, so it is
`pick-list.demo.tsx`. Its merge handler stops spreading both branches into
arrays only to spread them into a Set.

Demo source is what consumers copy, so `Selection` comes from
`@marigold/components` rather than a transitive react-aria package, and the
inert `space={2}` and `[!code highlight:1]` count are gone.
@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3dbe62d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@marigold/theme-rui Minor
@marigold/system Minor
@marigold/components Minor
@marigold/docs Minor
@marigold/icons Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

The v18 manifest's `componentDependencies` names the components whose
implementation hardcodes tokens the release added, so a consumer on a
standalone theme is told to define them rather than discovering a
partially unstyled component. It listed `SelectList` alone.

`GridSelectionIndicator` is now rendered by `ListView` as well, and its
single-select radio still hardcodes `selected-bold`,
`selected-bold-foreground` and `disabled-surface`. Without an entry,
`reportTokenDependencies` stays silent for a `ListView` with
`selectionMode="single"`, because it only warns for components it finds
imported.

The multiple branch needs no entry: it is a themeable `<Checkbox>`.
Both the docs page and the changeset said an unslotted `<Badge>` pushes
the text of every row in the list out of line. It does not. Each
`ListView.Item` is its own grid inside a `flex flex-col` list, so the
widened `auto` track is scoped to the row that carries the badge. That
row stops lining up with the others, which do not move.

Same visible symptom, wrong mechanism, in text that ships to consumers.
The test named "keeps the interactive cursor off a disabled row"
rendered no disabled row and asserted that a class substring was present
on an enabled one, so it could not fail for the cascade it exists to
pin.

Unit tests run in real Firefox with the theme CSS loaded, so it now
disables a row and reads the computed cursor off both. Dropping the
`not-disabled:` gate fails it with `expected 'pointer' to be
'not-allowed'`, which is the regression itself.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@sarahgm

sarahgm commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

/run-chromatic

All six new selection stories were becoming Chromatic baselines. Two of
them carry no visual information the other four do not.

`SelectionWithRowActivation` renders the same two rows in the same mode
as `MultipleSelection` with nothing selected. Its only arg difference is
`onAction`, which has no visual effect, so the snapshot is a duplicate.
The gesture switch it exists for is pinned by its tests.

`WithActionBar` starts with an empty selection, and `ActionBar` is
closed while `selectedItemCount` is 0, so the bar never reaches the
snapshot. What Chromatic captured was `MultipleSelection` plus a row.

The four that remain each hold a state nothing else does: the radio mark
over a selected row, the bare checkbox row, an indicator beside the
actions cell, and the badge alignment case.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

`onSelectionChange` can hand back the string `'all'`, not just a `Set`, and
Cmd/Ctrl+A reaches it in any multi-select list. The page said select-all was
missing and gave a keyboard inventory without the shortcut, so a reader had
every reason to assume `'all'` could not occur here. Reading `.size` off it
returns `undefined`, which lands as a wrong number in a confirmation rather
than an error.

Also names `disallowEmptySelection` where Escape is load-bearing: it blocks
Escape too, and Escape is the only documented way out of the state where
Enter does nothing.
Nothing covered the `'all'` branch, though every demo guards it and the count
it feeds drives a destructive confirmation. Two tests: Cmd/Ctrl+A reports the
string rather than a filled Set, and a controlled `'all'` marks every row.

`Mod` resolves off `navigator.platform`, the same read react-aria's `isMac()`
makes, so the story test agrees with the implementation on either platform
instead of hardcoding one.
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@sarahgm

sarahgm commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

/run-chromatic

@sarahgm
sarahgm marked this pull request as ready for review September 1, 2026 13:41
@OsamaAbdellateef
OsamaAbdellateef self-requested a review September 4, 2026 11:09
@@ -1,10 +1,13 @@
import { useState } from 'react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Visual Regression Tests — not run

This PR changes UI-affecting files (packages/components/src/**, themes/**, and several *.stories.tsx files) but the Visual-Regression-Tests workflow has not run for this branch (feat/DST-1665-listview-selection).

The PR description itself calls out three visual changes to review (selection indicator column, <SelectList> indicator drop, focus-ring style). Please trigger the check by commenting /run-chromatic on this PR, or by manually dispatching the workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:docs Improvements or additions to documentation type:feature New feature or component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants