Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, ChevronsUpDown, CircleDashed, Lock, Plus, RefreshCcw, Search, Users } from "lucide-react"

import { cn } from "@/lib/utils"
import type { AssigneePerson, AssigneePickerStatus, AssigneePresence } from "./assignee-picker.contract"

/* ------------------------------------------------------------------ *
 * Presence
 *
 * Presence is categorical data, so its dot rides the chart tokens rather than
 * a hard-coded green / amber: re-theme the chart palette and every face
 * re-skins with it. `offline` is deliberately tint-free — three coloured

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/assignee-picker.json

Prompt

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-disabled plus a guard in the handler is what keeps them reachable, where the native disabled attribute 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.

On This Page