PR: #583: feat(app): EditableChoice primitive (single|multiple) + converge EditableStatusPill [Story-2330] by @anyamshwr
View PR →

Story-2330: EditableChoice (single|multiple) + EditableStatusPill convergence

The Editable Surface vocabulary (Spike-2316) froze four kinds of edit-in-place value: Field, Block, List, and Choice. The first three are text and run TipTap inside. Choice is different — it is a pick, not a typing surface: a value on the page that clicks open into a picker, commits optimistically, and rolls back if the server says no.

We already had one — hiding inside editable-status-pill.tsx as a bespoke one-off: shadcn DropdownMenu + hand-rolled optimistic state. The Epic is about to need the same pattern for more values. Copying the pill's code per surface is how an app grows five slightly-different pickers with five slightly-different save behaviors. This Story extracts the mold from the one working part, then recasts the part from the mold.

The idea in one line

Extract the picker chrome into EditableChoice; leave the mutation with the caller — the primitive owns how a choice opens, shows, and commits; the caller owns what the choice means and how it saves.

The API — one component, two modes

ModeHTML precedentValue shapeCommit point
single (default) <select> string | null On pick — the menu closes, onSave(next) fires (only if changed).
multiple <select multiple> readonly string[] On menu close — toggles accumulate in a draft, ONE onSave(next[]) fires (only if the set changed).

The mode is a discriminated union, so passing an array to single mode (or a single-save handler to multiple mode) is a compile error, not a runtime surprise. Same one-prop pattern as EditableSurface's multiline.

Why the primitive is controlled (no optimism inside)

AGENTS.md "Optimistic writes" puts optimistic state next to the thing that can roll it back — the caller's mutation. If the primitive flipped its own display state, a failed write could not revert it (the prop never changed, so no re-sync fires). So EditableChoice renders value, fires onSave, and nothing else. The pill keeps its local-state-seeded-from-prop + rollback + toast — unchanged, and now the only thing left in its file.

The render slot — how convergence stays byte-identical

Each option may carry a render node. When present it fully owns the option's presentation in both the trigger and the menu rows — which is exactly how the pill keeps rendering StatusPills everywhere. Default (label-only) rows get a check glyph on the selected row (UX guideline #16); custom renders own their selected treatment, so the pill's menu looks exactly as before.

<EditableChoice
  ariaLabel={tMatter("changeStatus")}
  onSave={pick}
  options={STATUSES.map((s) => ({ id: s, label: t(s), render: <StatusPill …/> }))}
  value={value}
/>

The trigger classes (rounded-md outline-none transition-opacity hover:opacity-80), menu alignment (align="start"), row gap, and the no-op guard on picking the current value are the pill's exact previous behavior, now living in the primitive.

Provenance parity

The primitive carries the same seam as EditableSurface: provenance / provenanceLabel / onConfirm / onReject, rendered by the shared EditableSurfaceMarker. canonical and locked values (and readOnly) render as plain content — no menu, no hover affordance.

Evidence

Storybook-first (binding): 8 stories shipped and verified in the running catalog before any live wiring — Single, Empty placeholder, Custom option rendering (the pill seam), Multiple chips, Multiple empty, Read only, Provenance (proposed, with ratify), and Rollback on error (fake server rejects every pick; the value flips instantly, then reverts).

Checks: pnpm lint 0 errors · pnpm typecheck clean · pnpm test — all failures present on a clean tree without this diff (Windows CRLF freshness gates + network-dependent route tests; proven by stash-and-rerun) · pnpm build green.

Honest seams (verification doctrine)

multiple mode has no live consumer yet. It ships Storybook-verified with the API frozen. The first real wiring (e.g. participant labels) is where the system boundary gets exercised — that story must not treat this one's fixtures as proof.

ui/EditableSelect is superseded. Its own docblock anticipated this styled-popover upgrade (UX #16). Its only consumers live in add-matter/_legacy/; deletion rides the legacy-wizard cleanup as a follow-up Chore rather than expanding this Story's scope. EditableChips is NOT superseded — it is free-text tag entry, not an option pick.

What died

All bespoke dropdown/picker code in editable-status-pill.tsx — the file now contains only the option catalog, the optimistic value + rollback, and the mutation. The next choice-shaped value on any surface starts from EditableChoice, not from a copy of a copy.

Files changed

3 file(s), +458 / -35 lines

Path Role + -
src/components/matter-detail/editable-status-pill.tsxComponent+23-35
src/components/shared/editable-choice.stories.tsxComponent+216-0
src/components/shared/editable-choice.tsxComponent+219-0