Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  type Announcements,
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core"
import {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/poll-composer.json

Prompt

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-invalid and 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 Espresso and ESPRESSO ship 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, and now arrives as a prop. A clock read during render is impure: it desyncs SSR from hydration and makes every screenshot different.

On This Page