Resolved: light

Entity & tag pickers

The STD forms picker set (src/components/ds/forms/) — thin compositions over the @phauna/ds overlays Combobox + TagInput, on the Surface primitive. Everything takes plain props + callbacks (no server actions, no Prisma rows): EntityPicker resolves a known entity pool into removable tokens; TagPicker mints free-form tags. Flip the theme in the top bar to confirm every surface re-skins from the semantic tokens.

EntityOption
The selectable shape — the DS ComboboxOption ({ value, label }) plus optional icon/description the tokens may show (the dropdown only renders label). value is the stable id.
controlled
EntityPicker is selected/onSelectedChange; TagPicker is value/onChange. Both are fully controlled — the component holds no selection of its own.
Surface
Both pickers compose the Surface primitive: a flat field shell wrapping a recessed (inset) token / suggestion tray, so the chrome tracks the DS elevation + tone tokens.
presentational
No fetch, no router. asyncLoad is the one async seam (a Promise the caller wires to its own search); everything else is callbacks.

EntityPicker

A searchable multi-entity picker. The Combobox is driven purely as an add surface (its value stays null; each pick is promoted into the token tray), so the chosen entities render as our own removable DS Badge tokens — which unlocks custom token markup, selection caps, async results, and a full keyboard model the built-in chips don't expose.

  • NYC Chapter

selected: [t-nyc]

EntityPicker — multi-select, removable Badge tokens
  • Ada Lovelace
  • Cyrus Tang

2/3 selected

maxSelections={3} — field disables at the cap
  • Atlanta Organizers
  • SF Bay Team
renderToken — custom token markup via the render slot

type to filter; results resolve after ~450ms, dropdown capped at 50

asyncLoad + maxResults — debounced remote search over 600 rows
items / selected
items is the selectable pool (EntityOption[]); selected is the controlled selection. Already-selected entities are filtered out of the dropdown.
onSelectedChange
Called with the next EntityOption[] on every add or remove.
search / onSearchChange
Optional controlled query. Omit both to let the picker manage its own query state internally.
asyncLoad?
(query) => Promise<EntityOption[]>. When set, the dropdown shows debounced remote results (stale responses dropped, internal filtering off) and emptyText flips to loadingText while pending. debounceMs defaults to 250.
renderToken?
(item, { onRemove, removeLabel, index }) => ReactNode. Replaces the default removable Badge with custom markup; the default token honours item.icon and tokenTone.
maxSelections?
Caps the selection; the search field disables at the cap and a live count (n/max) is announced.
maxResults
Caps options rendered in the dropdown (Combobox limit) — a cheap guard for large pools. Default 100; for true windowing feed a virtualized list via asyncLoad.
keyboard / a11y
Tokens use a roving tabindex (←/→/Home/End move between them); Backspace/Delete/Enter/Space on a token removes it; Backspace in the empty field removes the last token. Remove controls carry an aria-label; the group + tray are labelled and focus is restored after a removal. Transitions respect prefers-reduced-motion.

TagPicker

The free-tag sibling: type + Enter (or comma) to mint a tag, Backspace on an empty field to drop the last, click the × on any chip. A thin layer over the DS TagInput that adds the value hygiene callers usually want — transform, validate, and optional one-click suggestions.

fostertransport

Press Enter or comma to add; Backspace on an empty field removes the last.

TagPicker — type + Enter (or comma) to create a tag
urgent
suggestions — one-click quick-add chips
beagle

Tags are lower-kebab-cased; spaces become hyphens.

transform + validate + maxTags — slugified, capped at 5
value / onChange
Controlled string[] of tags; onChange fires with the next list on add or remove.
transform?
(raw) => string. Normalises a raw tag before it commits — e.g. lowercase, strip a leading #, collapse spaces to hyphens. Applied before the dedupe + validate checks.
validate?
(tag, existing) => true | false | string. Rejects a tag; a string is shown as the inline error and the field flips to invalid styling.
suggestions?
One-click quick-add chips rendered below the field (in a recessed Surface tray); already-present tags are hidden and the tray disappears at maxTags.
maxTags / invalid / helpText
maxTags caps the count (DS TagInput max); invalid forces the error styling from outside; helpText sits under the field until an error replaces it.