Poll Composer
A create-a-poll form — a question, 2 to 10 reorderable option rows, duplicate answers marked on both halves, an allow-multiple switch, a duration menu and a live ballot preview, emitting a typed draft on every edit.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/poll-composer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PollComposer" component using
@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, lucide-react and the
shadcn dropdown-menu primitive. It is the create-a-poll form: a question, a
clamped list of reorderable option rows, an allow-multiple switch, a duration,
and a preview of the ballot voters will get. It owns the editor state and emits
a typed draft; it never publishes anything itself.
Contract
- export interface PollDuration { minutes: number; label: string }
- export const POLL_DURATIONS: PollDuration[] — 1 hour / 6 hours / 1 day /
3 days / 1 week, used when the consumer passes none.
- export interface PollOption { id: string; text: string } — id is minted by the
composer and survives edits, reorders and removals; text is already trimmed.
- export type PollProblemCode = "question-empty" | "question-too-long" |
"not-enough-options" | "too-many-options" | "duplicate-options" |
"option-too-long"
- export interface PollProblem { code: PollProblemCode; message: string;
optionIds: string[] } — message is the sentence shown and announced verbatim;
optionIds is empty for poll-level problems.
- export interface PollDraft { question: string; options: PollOption[];
allowMultiple: boolean; durationMinutes: number | null; closesAt: number |
null; valid: boolean; problems: PollProblem[] } — options holds only rows with
text, in display order, so the draft is the payload rather than the editor
state.
- export interface PollComposerSeed { question?: string; options?: string[];
allowMultiple?: boolean; durationMinutes?: number }
- export interface PollComposerProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue"|"onChange"|"onSubmit">
{ now: string | number | Date; defaultValue?: PollComposerSeed;
onChange?: (draft: PollDraft) => void; onSubmit?: (draft: PollDraft) => void;
onRefuse?: (message: string) => void; minOptions?: number (2);
maxOptions?: number (10); questionMaxLength?: number (140);
optionMaxLength?: number (80); durations?: PollDuration[] (POLL_DURATIONS);
showPreview?: boolean (true); showMultipleToggle?: boolean (true);
timeZone?: string ("UTC"); locale?: string ("en-US");
questionLabel?, questionPlaceholder?, optionsLabel?, submitLabel?, label?:
string; disabled?: boolean (false) }
- forwardRef<HTMLDivElement>, remaining props spread on the root, cn() merges
className.
- `now` is required and never read from the clock during render: closesAt is
`now + durationMinutes * 60000`. A Date.now() read in render is impure,
desyncs SSR from hydration and makes screenshots non-reproducible.
Behavior
- Editor state = { question, rows: {id,text}[], allowMultiple, durationMinutes }.
The seed runs once through useState's initializer; `defaultValue` changing
later is ignored, and a seed shorter than minOptions is padded with blank rows.
- Every accepted edit goes through one commit(next) that sets state and calls
onChange(buildDraft(next)) in the same handler — no effect, so the payload can
never lag a render behind the fields.
- Duplicate detection: normalise each row as trim → collapse inner whitespace →
toLocaleLowerCase(locale), bucket the non-blank rows by that key, and mark
EVERY member of a bucket of two or more, not just the later one — the author
decides which wording survives, and marking only one implies the other is
right. Each marked row gets aria-invalid, a destructive border and the line
"Same answer as option 2" / "…options 2 and 4" naming its twins by 1-based
position. Blank rows never collide with each other.
- Validation produces `problems` in field order: question empty → question over
its cap → fewer than minOptions rows with text → more rows than maxOptions →
duplicates → any option over its cap. valid is problems.length === 0. Only the
duplicate and over-length marks show while typing; the question and count
messages appear after the first refused submit, so the form does not shout at
a draft the author has not finished.
- Submit is aria-disabled while invalid but still clickable: the press announces
problems[0].message and moves focus to its culprit — the question field, the
first blank row, or the later half of the earliest duplicate pair. A one-shot
ref is read and written in the same synchronous turn, so a double click or a
held Enter fires onSubmit exactly once per press; a short timer releases it and
is cancelled on unmount.
- Add: the button appends, Enter inside a row inserts directly below that row,
and focus lands in the new field. At maxOptions both refuse out loud instead of
going dead.
- Remove: the bin, or Backspace in an already-empty row. The removed row unmounts
under the user, so a successor is computed from the pre-commit array and focus
is restored after the paint — the row that slides into the freed slot (clamped
to the last row), or the row above when Backspace triggered it, caret at the
end. Focus is never allowed to fall back to <body>. At minOptions removal is
refused with a sentence.
- Reorder has four ways in, and dragging is never the only one: the grip drags
(PointerSensor, 4px activation) or picks up with Space and moves with the arrow
keys (KeyboardSensor + sortableKeyboardCoordinates); per-row Move up / Move
down buttons cover pointer users who cannot drag; Alt+ArrowUp / Alt+ArrowDown
inside a field moves the row without leaving the text (a bare arrow still
belongs to the caret). Rows keep their React key across a move, so the same DOM
node is re-inserted and a caret inside it survives.
- Keyboard map: Enter in the question walks to the first blank option; Enter in
an option inserts one below; Backspace in an empty option removes it and takes
the caret up; Alt+↑/↓ moves a row; Space on the grip picks up, arrows move,
Space drops, Escape cancels; Tab reaches every control including the ones that
are aria-disabled. Enter never submits a surrounding form from any field.
event.nativeEvent.isComposing short-circuits every handler so an IME keeps its
keys.
- ARIA contract: root role="group" with aria-label; the option list is a
role="list" labelled by the Options heading; each field has an sr-only label
and, when marked, aria-invalid plus aria-describedby pointing at its own
message; the switch is a real button with role="switch" + aria-checked; the
duration menu is a dropdown radio group; dnd-kit's default announcements are
replaced so positions are read instead of raw ids; one polite aria-atomic
role="status" region carries every report and refusal, and clears itself after
~4s so the same refusal can be announced twice.
- disabled is aria-disabled plus handler guards, never the native attribute:
fields go readOnly so a caret already inside one keeps its place, buttons stay
focusable and clickable, and the dropdown is held closed through its open prop.
- The preview is a picture of a ballot, not a ballot: markers are aria-hidden
decoration, nothing inside is clickable, blank rows show as dashed ghosts
saying they will not ship, and the marker is a circle for single choice and a
square for multiple.
- Edge cases to keep: an unparsable `now` leaves closesAt null and the preview
drops the absolute time instead of printing "Invalid Date"; an unknown IANA
zone or a malformed locale tag falls back rather than throwing; durations={[]}
removes the menu and pins durationMinutes/closesAt to null; a seed longer than
maxOptions is kept intact and blocks submission instead of being silently
truncated; minOptions is clamped to at least 1 and maxOptions to at least
minOptions.
- Cleanup: the feedback timer and the submit-lock timer are both cleared on
unmount; the reduced-motion media query is subscribed through
useSyncExternalStore and unsubscribes itself; input nodes are removed from the
ref map by the ref callback when a row unmounts.
Rendering & styling
- Semantic tokens only: bg-background / bg-card / bg-muted / bg-primary /
text-primary-foreground / text-foreground / text-muted-foreground /
text-destructive / border / border-destructive / ring-ring / accent. No hex,
rgb() or oklch() anywhere; dark mode comes free from the tokens.
- cn() merges every className, including the consumer's on the root.
- prefers-reduced-motion: dnd-kit writes its settle transition inline, so a
CSS-only variant cannot beat it — read the query and pass transition:
undefined instead. Every other transition carries motion-reduce:transition-none.
Reordering, adding, removing and submitting all keep working with motion off.
- focus-visible:ring-2 ring-ring on every control; aria-disabled styling is
cursor-not-allowed + reduced opacity and deliberately NOT pointer-events-none,
because the click is what produces the spoken refusal.
- Rows are a flex line: grip, 1-based number, field (min-w-0 so long text does
not blow the row out), move up, move down, remove. The duplicate message sits
under the field so marking a row never reflows the controls.
Customization levers
- Density: drop the preview (showPreview), the switch (showMultipleToggle) or
the duration menu (durations={[]}) to get a compact composer; the remaining
parts keep working untouched.
- Bounds and caps: minOptions / maxOptions / questionMaxLength / optionMaxLength
are the whole clamp story — the counters, the hints and the problem sentences
all read from them.
- Duration presets: pass your own PollDuration[] (labels are shown verbatim in
the trigger, the menu and the preview line); pair it with timeZone + locale to
print the closing time the way your product does elsewhere.
- Copy: questionLabel / questionPlaceholder / optionsLabel / submitLabel / label
cover the visible strings; onRefuse hands you the exact refusal sentence if you
would rather route it into a toast or a form-level error summary.
- Colour: the accent is bg-primary on the submit button and on the switch track;
every warning is text-destructive. Swap those two tokens and the whole
composer follows.
- Behaviour: onChange for autosave and live validation, onSubmit for the create
call. Wire onSubmit to a mutation and keep the composer mounted — it is
uncontrolled by design, so a successful create should unmount it or remount it
with a fresh key.Concepts
- Mark both halves — a duplicate is a relationship, not an offender. Both rows go
aria-invalidand each names the other by position, because only the author knows which wording should survive. - Normalise before comparing — trim, collapse inner runs of whitespace, fold case in the given locale. Without it
EspressoandESPRESSOship as two answers and split the vote. - Focus handoff — a removed row unmounts under the user, so its successor is chosen from the pre-commit array and focused after the paint: the row that slid into the slot, or the one above when Backspace emptied it. Focus never lands on
<body>. - A gesture is never the only path — drag, Space-then-arrows on the grip, Alt+arrows inside the field, and plain move buttons are four ways to the same reorder. Reduced motion drops the settle animation and nothing else.
- Refuse and explain — the submit button stays clickable while invalid; the press is what produces the sentence and puts the caret on the field that has to change. A dead button explains nothing.
- Injected instant — the closing time is
now + duration, andnowarrives as a prop. A clock read during render is impure: it desyncs SSR from hydration and makes every screenshot different.
Font Picker
A typeface field where every option is rendered in its own face — grouped sans / serif / mono / display, previewed with your own pangram, with system stacks always present and remote families fetched through an injected loader that reports loading and failure per font.
Calculator Input
A number field that does arithmetic — type =2+3*4 or plain 2+3*4 and it commits 14, parsed by a hand-rolled parser that never calls eval.