Questionnaire
A one-question-at-a-time survey flow with choices, free answers, and validation.
Installation
Included in poetry-ui — available as
poetry_questionnaire
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add questionnaire
Default
<%# The hero flow: a required single choice with a free answer, an
optional multi-select (Skip appears), and a required closer.
Letter shortcuts answer with one keystroke; Cmd/Ctrl+Enter
confirms; ArrowLeft/Right navigate. The root is a REAL form -
answers submit as ordinary params. %>
<%= poetry_questionnaire(url: "#", http_method: :get, shortcuts: :letters, class: "w-full max-w-md") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "direction", title: "What should the agent build next?",
description: "Choose a direction or describe another task.",
required: true) do |item| %>
<% item.with_choice(value: "tool-calls", label: "Tool call timeline",
description: "Show what the agent ran and what came back.") %>
<% item.with_choice(value: "approvals", label: "Approval checkpoints",
description: "Ask before sensitive or destructive actions.") %>
<% item.with_choice(value: "handoffs", label: "Sub-agent handoffs",
description: "Make delegated work easier to follow.") %>
<% item.with_input(label: "Another agent feature", placeholder: "Describe another feature…") %>
<% end %>
<% q.with_item(name: "signals", title: "What should every progress update include?",
description: "Select all that apply, or skip this question.",
multiple: true) do |item| %>
<% item.with_choice(value: "progress", label: "Progress") %>
<% item.with_choice(value: "decisions", label: "Decisions") %>
<% item.with_choice(value: "risks", label: "Risks") %>
<% item.with_choice(value: "next-step", label: "Next step") %>
<% end %>
<% q.with_item(name: "timing", title: "When should work begin?",
description: "Choose when the agent should begin the work.",
required: true) do |item| %>
<% item.with_choice(value: "now", label: "Start now") %>
<% item.with_choice(value: "next-cycle", label: "Next development cycle") %>
<% item.with_choice(value: "backlog", label: "Add it to the backlog") %>
<% end %>
<% end %>
Animated
<%# Entrance animation per question: with_item(class:) merges onto the
item fieldset, and data-active:animate-in runs each time an item
becomes the visible one (hidden -> shown restarts the animation).
motion-reduce:animate-none respects the OS setting. %>
<% item_class = "data-active:animate-in data-active:fade-in-0 " \
"data-active:slide-in-from-bottom-2 data-active:duration-300 " \
"motion-reduce:animate-none" %>
<%= poetry_questionnaire(url: "#", http_method: :get, class: "w-full max-w-md") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "task", title: "What should the agent do?",
description: "Choose the task for this run.",
required: true, class: item_class) do |item| %>
<% item.with_choice(value: "implement", label: "Implement the requested change") %>
<% item.with_choice(value: "debug", label: "Debug the current behavior") %>
<% item.with_choice(value: "review", label: "Review the implementation") %>
<% end %>
<% q.with_item(name: "review", title: "How should the work be reviewed?",
description: "Select the verification depth.",
required: true, class: item_class) do |item| %>
<% item.with_choice(value: "targeted", label: "Targeted checks on changed code") %>
<% item.with_choice(value: "full", label: "The full test suite") %>
<% end %>
<% q.with_item(name: "delivery", title: "How should the result land?",
required: true, class: item_class) do |item| %>
<% item.with_choice(value: "pr", label: "Open a pull request") %>
<% item.with_choice(value: "branch", label: "Push a branch for review") %>
<% end %>
<% end %>
Card
Create an agent task
<%# The questionnaire inside a Card shell. Upstream re-parents each
question's title into the CardHeader with render props; poetry's
generative anatomy keeps the legend inside its fieldset (the
accessible grouping), so the card supplies the frame and the flow
runs in the content area. Number shortcuts answer with one key. %>
<%= poetry_card(class: "w-full max-w-md") do |card| %>
<% card.with_title { "Create an agent task" } %>
<% card.with_description { "Two questions before the run starts." } %>
<%= poetry_questionnaire(url: "#", http_method: :get, shortcuts: :numbers,
submit_label: "Create task") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "task", title: "What should the agent work on?",
required: true) do |item| %>
<% item.with_choice(value: "fix", label: "Fix the failing tests") %>
<% item.with_choice(value: "refactor", label: "Refactor the data layer") %>
<% item.with_choice(value: "docs", label: "Write the missing docs") %>
<% end %>
<% q.with_item(name: "output", title: "How should the work be handed off?",
required: true) do |item| %>
<% item.with_choice(value: "summary", label: "A written summary") %>
<% item.with_choice(value: "files", label: "The changed files") %>
<% item.with_choice(value: "review", label: "A review-ready pull request") %>
<% end %>
<% end %>
<% end %>
Custom progress
<%# with_progress { custom } owns the readout (data-custom keeps the
auto text off), while the controller holds data-current/data-total
live on the progress element - so checkpoint segments are pure CSS
(in-data-[current=N] variants) and the [data-progress-count] child
receives the live "X of Y". Submit is relabeled via submit_label:. %>
<%= poetry_questionnaire(url: "#", http_method: :get, submit_label: "Finish plan",
class: "w-full max-w-md") do |q| %>
<% q.with_progress(class: "w-full") do %>
<span aria-hidden="true" class="mb-2 flex gap-1.5">
<span class="h-1.5 flex-1 rounded-full bg-primary"></span>
<span class="h-1.5 flex-1 rounded-full bg-muted in-data-[current=2]:bg-primary in-data-[current=3]:bg-primary in-data-[current=4]:bg-primary"></span>
<span class="h-1.5 flex-1 rounded-full bg-muted in-data-[current=3]:bg-primary in-data-[current=4]:bg-primary"></span>
<span class="h-1.5 flex-1 rounded-full bg-muted in-data-[current=4]:bg-primary"></span>
</span>
Checkpoint <span data-progress-count>1 of 4</span>
<% end %>
<% q.with_item(name: "scope", title: "What is in scope for this pull request?",
required: true) do |item| %>
<% item.with_choice(value: "single", label: "A single focused change") %>
<% item.with_choice(value: "feature", label: "One feature across a few files") %>
<% item.with_choice(value: "refactor", label: "A wider refactor") %>
<% end %>
<% q.with_item(name: "strategy", title: "How should the change be built?",
required: true) do |item| %>
<% item.with_choice(value: "incremental", label: "Small reviewable commits") %>
<% item.with_choice(value: "spike", label: "Spike first, then clean up") %>
<% end %>
<% q.with_item(name: "tests", title: "How should it be verified?",
required: true) do |item| %>
<% item.with_choice(value: "unit", label: "Unit tests with the change") %>
<% item.with_choice(value: "integration", label: "An integration test per behavior") %>
<% item.with_choice(value: "manual", label: "A manual verification checklist") %>
<% end %>
<% q.with_item(name: "delivery", title: "How should it land?",
required: true) do |item| %>
<% item.with_choice(value: "review", label: "Open for review") %>
<% item.with_choice(value: "pair", label: "Walk through it together") %>
<% end %>
<% end %>
Dialog
<%# A clarification flow inside a Dialog: the trigger opens it, the
questionnaire runs within the content area, and submitting sends the
answers as a real form (in an app, respond with a Turbo Stream that
closes the dialog and continues the run). %>
<%= poetry_dialog do |dialog| %>
<% dialog.with_trigger(variant: :outline) { "Open clarification" } %>
<% dialog.with_title { "Clarify the task" } %>
<% dialog.with_description { "Two questions before the agent starts." } %>
<%= poetry_questionnaire(url: "#", http_method: :get, submit_label: "Send answers",
class: "pt-2") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "scope", title: "Which files are in scope?",
required: true) do |item| %>
<% item.with_choice(value: "component", label: "Component only") %>
<% item.with_choice(value: "feature", label: "Complete feature directory") %>
<% item.with_choice(value: "workspace", label: "Any related workspace file") %>
<% end %>
<% q.with_item(name: "tests", title: "How should the change be verified?",
required: true) do |item| %>
<% item.with_choice(value: "suite", label: "Run the affected test suite") %>
<% item.with_choice(value: "spot", label: "Spot-check the behavior") %>
<% end %>
<% end %>
<% end %>
Shortcuts
<%# shortcuts: :numbers labels each choice 1-9 (server-rendered chips);
pressing the key focuses and selects the choice. Text targets are
exempt, so typing in a free answer never triggers them. %>
<%= poetry_questionnaire(url: "#", http_method: :get, shortcuts: :numbers, class: "w-full max-w-md") do |q| %>
<% q.with_item(name: "cadence", title: "How often should we check in?",
description: "Press 1, 2, or 3 to answer.", required: true) do |item| %>
<% item.with_choice(value: "daily", label: "Daily") %>
<% item.with_choice(value: "weekly", label: "Weekly") %>
<% item.with_choice(value: "monthly", label: "Monthly") %>
<% end %>
<% end %>
Skip
<%# Skip renders only while the active item is OPTIONAL - it marks the
question skipped and advances (on the last question it submits).
Answering later clears the skipped status. %>
<%= poetry_questionnaire(url: "#", http_method: :get, class: "w-full max-w-md") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "referral", title: "Where did you hear about us?",
description: "Optional - skip if you would rather not say.") do |item| %>
<% item.with_choice(value: "friend", label: "A friend or colleague") %>
<% item.with_choice(value: "social", label: "Social media") %>
<% item.with_choice(value: "search", label: "Search") %>
<% end %>
<% q.with_item(name: "team-size", title: "How big is your team?", required: true) do |item| %>
<% item.with_choice(value: "solo", label: "Just me") %>
<% item.with_choice(value: "small", label: "2-10") %>
<% item.with_choice(value: "large", label: "More than 10") %>
<% end %>
<% end %>
Validation
<%# required: true gates Next - press it unanswered and the error shows
(role=alert), clearing the moment an answer lands. The server
remains the truth on submit: re-render an invalid item with error:
for the server-side message. %>
<%= poetry_questionnaire(url: "#", http_method: :get, class: "w-full max-w-md") do |q| %>
<% q.with_progress %>
<% q.with_item(name: "plan", title: "Which plan fits best?",
description: "Press Next without answering to see the validation.",
required: true) do |item| %>
<% item.with_choice(value: "starter", label: "Starter") %>
<% item.with_choice(value: "team", label: "Team") %>
<% end %>
<% q.with_item(name: "seats", title: "How many seats?", required: true) do |item| %>
<% item.with_choice(value: "five", label: "Up to 5") %>
<% item.with_choice(value: "twenty", label: "Up to 20") %>
<% end %>
<% end %>
API
Poetry::Ui::Questionnaire::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| default_item: | String | The initially active item by name; default is the first item. | |
| http_method: | Symbol | defaults to :post |
The form's HTTP verb. Named http_method (not method:) - an option named method would shadow Object#method. |
| id: | String | The root form's DOM id; item element ids derive from it. | |
| next_label: | String | defaults to "Next" |
The forward-navigation button's text. |
| previous_label: | String | defaults to "Previous" |
The back-navigation button's text. |
| shortcuts: | Symbol | one of: letters, numbers; |
nil (off), :letters (A, B, C...) or :numbers (1-9): server- rendered key labels + one-keystroke answering. |
| skip_label: | String | defaults to "Skip" |
The skip button's text (shown only while the active item is optional). |
| submit_label: | String | defaults to "Submit" |
The final submit button's text (replaces Next on the last item). |
| url: | String | required | The form's submit URL - answers post here as ordinary params. |
Slots
| Writer | Description |
|---|---|
| with_progress | Opts the progress readout in. Bare, it renders the live "Question X of Y" text; a block replaces the text; class: merges onto the progress element. |
Methods
| Method | Description |
|---|---|
| # | with_progress (bare) renders the auto \"Question X of Y\" text; with_progress { custom } replaces it (marked data-custom so the controller leaves it alone). class: merges onto the progress element (e.g. w-full for a full-width segment bar over the base w-fit). |
| #with_item(**, &block) | Adds one question. name: is the param key, title: the visible heading; the yielded builder takes answers (with_choice) and an optional free-text field (with_input). Hand-rolled (not a VC slot): the yielded builder is a plain object, and ViewComponent lambda slots only forward to component returns - a builder return wraps as nil. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=questionnaire] | The root <form> the controller drives - navigation, validation gating, and the keyboard map all ride here |
| [data-slot=questionnaire-progress] | The polite progressbar ('Question X of Y'; block content replaces the text). Always carries live data-current/data-total, and a [data-progress-count] child gets the live 'X of Y' text - custom segment bars ride data-[current=N] variants |
| [data-slot=questionnaire-item] | One question <fieldset> - exactly one is active |
| [data-slot=questionnaire-title] | The question heading - a real <legend> |
| [data-slot=questionnaire-description] | Muted copy under the title; its id rides the fieldset's aria-describedby |
| [data-slot=questionnaire-choices] | The answer grid for one item |
| [data-slot=questionnaire-choice] | One answer <label> wrapping its native input |
| [data-slot=questionnaire-choice-input] | THE native radio/checkbox - stretched invisibly over the row (paste of the input-otp posture) |
| [data-slot=questionnaire-choice-indicator] | The box/circle glyph (aria-hidden) - dot for radio, check for checkbox |
| [data-slot=questionnaire-choice-indicator-dot] | The radio dot (shown while checked) |
| [data-slot=questionnaire-choice-indicator-check] | The checkbox check icon (shown while checked) |
| [data-slot=questionnaire-choice-label] | The label column - answer text over the optional description |
| [data-slot=questionnaire-choice-description] | Muted copy under the answer text |
| [data-slot=questionnaire-choice-shortcut] | The key hint chip (aria-hidden; hidden unless the choice carries data-shortcut) |
| [data-slot=questionnaire-input-wrapper] | The free-text answer's positioning wrapper |
| [data-slot=questionnaire-input] | The free-text answer - a real text input named after the item |
| [data-slot=questionnaire-error] | The validation message (hidden until a navigation demands the answer; role=alert while shown) |
| [data-slot=questionnaire-actions] | The navigation row - Previous / Skip / Next / Submit (the buttons ride composed Buttons, so those elements belong to Button's anatomy, not this contract; each carries data-visible/data-hidden + hidden/inert) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| questionnaire-progress | data-custom | block content supplied - the controller leaves the text alone | — |
| questionnaire-progress | data-current | always - the active question number (live) | — |
| questionnaire-progress | data-total | always - the enabled question count (live) | — |
| questionnaire-item | data-name | always - the item's param name | — |
| questionnaire-item | data-active | the visible question (inactive items are hidden + inert) | — |
| questionnaire-item | data-status | always - the answer state | unanswered · answered · skipped |
| questionnaire-item | data-required | required: true - Skip hides and Next validates | — |
| questionnaire-item | data-multiple | multiple: true - checkbox choices named <name>[] | — |
| questionnaire-item | data-invalid | validation attempted and unanswered - the error shows | — |
| questionnaire-item | data-validated | a navigation has demanded this answer at least once | — |
| questionnaire-item | data-skipped | explicitly skipped (cleared by any interaction) | — |
| questionnaire-choice | data-type | always - the input kind | radio · checkbox |
| questionnaire-choice | data-checked | the choice is selected | — |
| questionnaire-choice | data-unchecked | the choice is not selected | — |
| questionnaire-choice | data-shortcut | shortcuts: on - the key label (A, B... or 1-9) | — |
| questionnaire-choice | data-disabled | choice or item disabled | — |
| questionnaire-choice-input | data-checked | selected | — |
| questionnaire-choice-input | data-unchecked | not selected | — |
| questionnaire-input | data-filled | has a value (counts as answered) | — |
| questionnaire-input | data-empty | blank | — |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--questionnaire | registers · value shortcuts (if) · keydown on keydown · submit on submit · reset on reset · change on change · input on input |
| progress | poetry--core--questionnaire | target progress |
| previous | poetry--core--questionnaire | previous · target previous |
| skip | poetry--core--questionnaire | skip · target skip |
| next_button | poetry--core--questionnaire | next · target next |
| submit_button | poetry--core--questionnaire | target submit |