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.
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.
| Mode | HTML precedent | Value shape | Commit 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.
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.
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.
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.
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.
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.
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.
3 file(s), +458 / -35 lines
| Path | Role | + | - |
|---|---|---|---|
| src/components/matter-detail/editable-status-pill.tsx | Component | +23 | -35 |
| src/components/shared/editable-choice.stories.tsx | Component | +216 | -0 |
| src/components/shared/editable-choice.tsx | Component | +219 | -0 |