Assignee Picker
A people picker with searchable presence-aware rows, a first-class Unassigned option, a recently-assigned shortcut group, single or multi assignment, blocked rows that say why, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/assignee-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AssigneePicker" component with zod and
lucide-react. No popover library: the panel is one absolutely positioned div, so
the component drops into any project without a portal stack.
Contract
- A zod schema (`assigneePersonSchema`, in a sibling contract file) is the single
source of truth for ONE person: { id, name, handle, avatarSeed, presence,
disabled?, reason? }. The props take z.infer of it, never a parallel
hand-written interface.
- presence: "online" | "busy" | "away" | "offline". It is REACH, not permission —
a person can be online and unassignable, or offline and perfectly assignable.
That is exactly why `disabled` is a separate field.
- `disabled` marks a person who cannot be NEWLY assigned (no project access,
deactivated, external contractor). It never hides them: a picker that silently
omits the teammate someone was told to assign sends that person hunting through
a list that will never contain the answer. `reason` is the sentence printed on
the row; when a directory API forgets it, the `disabledReason` prop supplies
one, so a blocked row is never unclickable for no visible cause.
- `avatarSeed` is a SEED, not a URL: it keeps a face stable across sessions,
servers and screenshots, costs zero network by default, and survives a dead
CDN. `avatarSrc?: (person) => string | undefined` upgrades it to a real image.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
DIRECTORY's own state, independent of any person's `disabled`: "error" means
the member request failed, while a teammate you may not assign is a healthy
"ready" list with a blocked row in it. `recentIds: string[]` (most recent
first) rides the same envelope, because assignment history is a per-user server
fact the component cannot derive from the list it was handed.
- Props: people; status; recentIds? ([]); multiple? (false); value? (controlled,
ALWAYS an array — single mode holds 0 or 1 id, which keeps one code path);
defaultValue?; onValueChange?(ids, people) — it hands back the resolved people
so the caller never has to look them up; avatarSrc?; maxRecent? (3, clamped
>= 0, and 0 removes the group); maxAvatars? (3, clamped >= 1); placeholder?
("Unassigned"); unassignedLabel?; searchPlaceholder?; disabledReason?;
emptyMessage?; errorMessage?; onRetry?; onInvite?; inviteLabel?; label?
("Assignee"); className plus the remaining div props, ref forwarded to the
root. Both clamps are Math.max(bound, Math.floor(Number.isFinite(x) ? x :
fallback)), so a NaN out of a config file cannot produce an empty control.
Behavior
- The trigger is a button (aria-haspopup="dialog", aria-expanded, aria-controls)
showing up to `maxAvatars` overlapping faces (-ml-2 with a ring cutout) plus a
"+N" chip, and a text summary: the placeholder when nobody is assigned, the one
name when exactly one person is, and a count once more than one is. The faces
answer WHO and the text answers HOW MANY — naming the first person and also
printing "+3" next to a "+1" avatar chip would put two different numbers on one
control. Both counts include ids that no longer resolve to a person, so nothing
is silently dropped from the total.
- Opening moves focus into the search field inside the panel, and that field —
not the trigger — is the role="combobox" (aria-autocomplete="list",
aria-controls pointing at the listbox, aria-activedescendant at the active
row). Focus never leaves it afterwards: option rows preventDefault on
mousedown, so a click commits without stealing focus.
- Rows are ONE flat ordered array, rebuilt from the query on every render:
[ "Unassigned", present when the query is empty or prefixes one of
"unassigned" / "nobody" / "no one" / "none" / "clear" ] + [ recentIds resolved
through the people map, deduped, capped at maxRecent, EMPTY QUERY ONLY ] +
[ every person whose name or handle contains the query ]. A leading "@" is
stripped from both sides, so "@ada" and "ada" find the same person. The array
INDEX is the identity used by aria-activedescendant and the arrow keys — not
the person id — because a person legitimately appears twice (Recent and All);
the React key therefore carries the section as well as the id.
- The three sections are contiguous, so each group renders from
rows.findIndex(section) as its offset and every row keeps its flat index. They
are real role="group" containers with aria-labelledby (aria-label for the
unlabelled assignment group), not decorative headers dropped into a listbox.
- Keyboard map. Trigger: Enter / Space click it open; ArrowDown / ArrowUp open it
too, with preventDefault or the page scrolls. Search field: ArrowDown / ArrowUp
move the active row and CLAMP at both ends (a listbox clamps, a radiogroup
wraps — a wrapping list makes "am I at the bottom?" unanswerable); Home / End
jump to the first / last row ONLY while the field is empty, because with text
typed those keys belong to the caret; Enter commits the active row; Backspace
on an empty query takes back the assignee added last; Escape clears a non-empty
query first and closes on the next press (undo the narrowest thing first),
with stopPropagation so a picker inside a dialog closes only itself; Tab closes
without preventDefault and lets focus move on.
- Committing: "Unassigned" emits []; a person toggles in multi mode and replaces
in single mode. Single mode closes the panel and restores focus to the trigger;
multi keeps the panel and the query up, because picking three people should be
three keystrokes, not three round trips through the trigger. Browsing never
writes, so Escape restores the committed value by construction rather than from
a snapshot.
- A blocked person is refused in the HANDLER, never with the native `disabled`
attribute — the browser blurs a node the instant it becomes disabled, and this
row has to stay reachable so its reason is announced. The guard is
`person.disabled && !isSelected`: removal is always allowed, because losing
access must not trap somebody on a task.
- Controlled and uncontrolled both work: `value` present = the parent owns it,
otherwise internal state moves and onValueChange still fires. Read the current
selection as `value ?? internal`, never as a ternary on a boolean flag — the
flag does not narrow the type and the whole component inherits "| undefined".
- A committed id that is NOT in `people` (someone left the org) is surfaced, not
repaired: the trigger still counts it, a note under the control names it in a
monospace span, and choosing Unassigned clears it. Auto-dropping it would
rewrite a deliberate assignment with a guess and nobody would ever know.
- A `recentIds` entry that no longer resolves is skipped silently — the opposite
choice on purpose, because a shortcut is not a claim about the current
assignment.
- Four first-class states, not && afterthoughts: loading = a placeholder the same
size as the real trigger, aria-busy plus one sr-only role="status"; empty (also
used when "ready" arrives with an empty array, since a trigger that opens onto
a hairline border reads as broken) = an explanation plus an optional invite;
error = role="alert", a whitespace-pre-wrap message so a request id survives
its line break, and an optional retry; ready = trigger plus panel.
- The panel can never outlive its branch: losing the directory closes it by
adjusting state DURING RENDER, so aria-controls never points at an id that has
left the document. Placement flips above the trigger when there is no room
below, measured inside a requestAnimationFrame (the panel is already mounted,
so it measures a real height instead of guessing). Everything is cancelled on
unmount and on dependency change: the outside-pointerdown listener
(pointerdown, not click, so the panel is gone before the press lands on
whatever is underneath), the focus frame, the placement frame. Closing always
resets the query and the active index.
Rendering & styling
- Semantic tokens only: bg-background + border for the trigger, bg-popover /
text-popover-foreground for the panel, bg-accent + text-accent-foreground for
the active row, text-primary for the check mark, bg-muted for monograms and the
"+N" chip, border-destructive/40 + bg-destructive/5 for the error envelope,
border-dashed for the empty branch and the Unassigned row. No hard-coded
colours anywhere.
- Presence is categorical data, so its dot rides var(--chart-2) / var(--chart-4)
/ var(--chart-5) for online / away / busy, and offline is deliberately
tint-free (bg-muted-foreground/50): three colours plus one grey read faster
than four colours competing. Re-theme the chart palette and every dot follows.
Colour is never the only carrier — the word ("Away") sits in the row's second
line and in its accessible name.
- The monogram tint is var(--chart-N) where N = (hash % 5) + 1 and hash is a
32-bit rolling hash of the seed, Math.imul(hash, 31) + charCodeAt(i), kept
unsigned with >>> 0 so a long seed cannot wrap negative and hand % a negative
index. Initials split by code POINT (Array.from), or a supplementary-plane CJK
name becomes half a character and renders as a replacement box.
- The face degrades in one step: avatarSrc -> img, an image that fails onError ->
the monogram. onError is not enough on its own: a server-rendered face whose URL
404s finishes failing BEFORE React attaches the handler, and an event that
already happened never fires again, so the browser's broken-image glyph would
sit there forever. A ref callback also asks the element what already happened
(complete && naturalWidth === 0) and flips the same flag on hydration, before
the first paint. The failure flag resets by adjusting state during render when
the URL changes, so a swapped avatar gets a fresh attempt instead of inheriting
the previous one's failure.
- Long names and handles use wrap-anywhere plus min-w-0, NOT break-words: only
overflow-wrap:anywhere lowers the min-content width, which is what actually
stops a 60-character service-account handle from widening the panel. The
trigger summary is the one place text truncates, and truncation there is
CSS-only — the full string stays in the DOM and in the accessibility tree.
- One persistent sr-only role="status" spells out the current assignment for the
whole ready branch, so a CHANGE is announced from an element the reader is
already watching; a second, conditional one announces the match count while a
query is active. Each row's accessible name is one sentence — name, handle,
presence, why it cannot be assigned, plus "Recently assigned" for shortcut
rows, which is how that group survives being drawn as a plain header.
- Avatars are aria-hidden: the row already names the person, and a second
announcement of the same name is noise. The panel fades in with animate-in
fade-in-0 zoom-in-95 and every transition is motion-reduce guarded; nothing
about selection depends on an animation.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Density: `maxAvatars` sets how many faces stack before "+N"; `maxRecent` sizes
the shortcut group (0 removes it entirely); the panel's max-h-64 and min-w-72
are the two numbers to edit for a taller list or a wider table cell.
- Which sub-blocks exist: omit `onInvite` and the empty branch stops offering a
way out; omit `onRetry` and the error branch stops offering one; drop the multi
footer line if your audience does not need the key hints.
- Selection policy stays with you: `multiple` picks the model, `onValueChange`
receives ids AND resolved people, and nothing is written unless you write it.
Pass `defaultValue` for uncontrolled, or drive `value` from your own store.
- Faces: `avatarSrc` is the entire avatar policy in one function — DiceBear
(https://api.dicebear.com/9.x/notionists/svg?seed=), your CDN, a data URL.
Return undefined for the monogram; re-key the tint hash if you would rather
group people by team colour than by seed.
- Presence mapping: PRESENCE_TINT and PRESENCE_LABEL are two small records. Add
"in a meeting", or point every value at one token for a presence-free
directory, without touching the row layout.
- Wording: `label`, `placeholder`, `unassignedLabel`, `searchPlaceholder`,
`disabledReason`, `emptyMessage`, `errorMessage`, `inviteLabel` are the copy
deck — translate them, do not fork the component. Two strings stayed inline
rather than becoming a ninth and tenth prop: the multi trigger summary
("N assigned") and the multi footer hint; both are one template literal each.
- Matching: matchesQuery is a name-or-handle substring test today. Swap it for a
subsequence or fuzzy match, or extend it to a team field, and nothing else has
to change — the rows array is its only consumer.Concepts
- Unassigned is an option, not the absence of one — clearing an assignee is something people do on purpose, so it is a row you can search for ("none", "nobody"), arrow to and select, and it reports an empty array through the same callback as everybody else. The alternative — clicking the selected row again — is a trick nobody discovers and a screen reader can never announce.
- Presence is reach, permission is
disabled— two independent fields because they answer different questions: "will this move today?" and "is this person allowed to touch it at all?". Somebody can be online and unassignable, or offline and perfectly assignable, and collapsing the two into one grey row loses both answers. - Blocked rows stay, and say why — a person you cannot assign keeps their place in the list, keeps arrow-key focus so the reason is read aloud, and simply refuses to commit.
aria-disabledplus a guard in the handler is what keeps them reachable, where the nativedisabledattribute would blur the row and delete the explanation with it. Removal is never blocked: losing access must not trap somebody on a task. - Recent is a shortcut, not a partition — the recently-assigned group repeats people who also appear further down, and it disappears the moment you type, because a shortcut that duplicates search results is noise. That deliberate duplication is why the keyboard walks a flat row index instead of a person id.
- Browsing never writes — moving the active row changes nothing but
aria-activedescendant, so Escape restores the committed value by construction rather than from a snapshot; and the first Escape only clears the filter, undoing the narrowest thing the reader did. - A stale assignee is surfaced, not repaired — an id that no longer resolves to a person still counts on the trigger and is named underneath it, waiting for a human decision. Silently dropping it would erase a deliberate assignment, and nobody would ever learn that it happened.
Checkbox Group
A flat fieldset of checkboxes with a genuinely indeterminate select-all, per-row descriptions, shift-range selection, and min/max limits that answer with a message instead of a dead control.
Password Generator
A crypto-backed password generator with a length slider, required character sets, look-alike exclusion, a live entropy readout and an explicit refusal for impossible rule sets.