Field
Wraps a form control with its label, hint, and validation message.
Installation
Included in poetry-ui — available as
poetry_field
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 field
Default
<%# The label/control quartet: the field owns the ids and yields
control_attributes - wire the control with them, never hand-write
aria wiring. %>
<div class="w-80">
<%= poetry_field(id: "field-email", label_text: "Email") do |field| %>
<%= poetry_input(type: "email", name: "email",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
Checkbox
Folders sync with iCloud Drive - access them from other devices.
<%# Checkbox runs: variant: :choices packs the horizontal fields tighter,
the label-sized legend names the run, and a lone toggle keeps its
hint stacked beside the box. %>
<div class="w-full max-w-xs">
<%= poetry_field_group do %>
<%= poetry_fieldset(legend: "Show these items on the desktop", legend_variant: :label,
hint: "Select the items you want to show.") do %>
<%= poetry_field_group(variant: :choices) do %>
<%= poetry_field(id: "field-disks", label_text: "Hard disks",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "disks", checked: true,
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<%= poetry_field(id: "field-external", label_text: "External disks",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "external",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<%= poetry_field(id: "field-cds", label_text: "CDs, DVDs, and iPods",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "cds",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<%= poetry_field(id: "field-servers", label_text: "Connected servers",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "servers",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<% end %>
<% end %>
<%= poetry_field_separator %>
<%= poetry_field(id: "field-sync", label_text: "Sync Desktop & Documents folders",
hint: "Folders sync with iCloud Drive - access them from other devices.",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "sync", checked: true,
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<% end %>
</div>
Choice card
<%# Choice cards: radio items with variant: :card render title +
description inside a selectable bordered label - the whole card
toggles the radio. %>
<div class="w-full max-w-xs">
<%= poetry_fieldset(legend: "Compute environment", legend_variant: :label,
hint: "Select the compute environment for your cluster.") do %>
<%= poetry_radio_group(name: "compute", value: "kubernetes",
label: "Compute environment") do |group| %>
<% group.with_item(value: "kubernetes", label: "Kubernetes", variant: :card,
description: "Run GPU workloads on a K8s cluster.") %>
<% group.with_item(value: "vm", label: "Virtual Machine", variant: :card,
description: "Access a cluster to run GPU workloads.") %>
<% end %>
<% end %>
</div>
Fieldset
<%# The group layer: a real <fieldset> named by a real <legend> (legend: is
required); hint: describes the group, and the FieldGroup inside owns
the stacking rhythm. %>
<div class="w-full max-w-sm">
<%= poetry_fieldset(legend: "Address information",
hint: "We need your address to deliver your order.") do %>
<%= poetry_field_group do %>
<%= poetry_field(id: "field-street", label_text: "Street address") do |field| %>
<%= poetry_input(name: "street", placeholder: "123 Main St",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<div class="grid grid-cols-2 gap-4">
<%= poetry_field(id: "field-city", label_text: "City") do |field| %>
<%= poetry_input(name: "city", placeholder: "New York",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<%= poetry_field(id: "field-zip", label_text: "Postal code") do |field| %>
<%= poetry_input(name: "zip", placeholder: "90502",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
<% end %>
<% end %>
</div>
Group
<%# FieldGroup stacks whole sections; FieldSeparator divides them. Each
section is a Fieldset with a label-sized legend over its own run of
toggles. %>
<div class="w-full max-w-xs">
<%= poetry_field_group do %>
<%= poetry_fieldset(legend: "Responses", legend_variant: :label,
hint: "Get notified when responses to long requests finish.") do %>
<%= poetry_field_group(variant: :choices) do %>
<%= poetry_field(id: "field-push-responses", label_text: "Push notifications",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "push_responses", checked: true, disabled: true,
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<% end %>
<% end %>
<%= poetry_field_separator %>
<%= poetry_fieldset(legend: "Tasks", legend_variant: :label,
hint: "Get notified when tasks you've created have updates.") do %>
<%= poetry_field_group(variant: :choices) do %>
<%= poetry_field(id: "field-push-tasks", label_text: "Push notifications",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "push_tasks",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<%= poetry_field(id: "field-email-tasks", label_text: "Email notifications",
orientation: :horizontal) do |field| %>
<%= poetry_checkbox(name: "email_tasks",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<% end %>
<% end %>
<% end %>
</div>
Horizontal switch
Require a one-time code at every sign-in.
<%# orientation: :horizontal is the boolean-control layout - the switch
sits left, label + hint stack to its right, box centered on the label
line. control_attributes still carries the id and aria-describedby. %>
<div class="w-80">
<%= poetry_field(id: "field-2fa", label_text: "Two-factor authentication",
hint: "Require a one-time code at every sign-in.",
orientation: :horizontal) do |field| %>
<%= poetry_switch(name: "two_factor", checked: true,
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
Responsive
<%# orientation: :responsive is container-driven: stacked by default,
label-left / control-right once the FieldGroup container passes the
md mark. Resize the window to see it flip. %>
<div class="w-full max-w-lg">
<%= poetry_fieldset(legend: "Profile", hint: "Fill in your profile information.") do %>
<%= poetry_field_group do %>
<%= poetry_field(id: "field-name", label_text: "Name",
hint: "Provide your full name for identification.",
orientation: :responsive) do |field| %>
<%= poetry_input(name: "name", placeholder: "Evil Rabbit", class: "@md/field-group:w-56",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
<div class="flex flex-col gap-2 @md/field-group:flex-row">
<%= poetry_button(type: :submit) { "Submit" } %>
<%= poetry_button(variant: :outline) { "Cancel" } %>
</div>
<% end %>
<% end %>
</div>
With error
We never share it.
can't be blank
<%# error: flips the field invalid - control_attributes carries
aria-invalid plus aria-describedby pointing at error then hint, and
required: arrives as aria-required (never the native attribute). %>
<div class="w-80">
<%= poetry_field(id: "field-work-email", label_text: "Work email",
hint: "We never share it.", error: "can't be blank",
required: true) do |field| %>
<%= poetry_input(type: "email", name: "work_email",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
With hint
Public, letters and dashes only.
<%# hint: renders below the control and lands in the control's
aria-describedby via control_attributes. %>
<div class="w-80">
<%= poetry_field(id: "field-handle", label_text: "Handle",
hint: "Public, letters and dashes only.") do |field| %>
<%= poetry_input(name: "handle",
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
With radio group
Applied at checkout.
<%# group: true is the role-bearing-control layout: the RadioGroup is a
role=radiogroup <div>, so the label drops for= and names the group via
aria-labelledby - control_attributes wires all of it. %>
<div class="w-80">
<%= poetry_field(id: "field-delivery", label_text: "Delivery speed",
hint: "Applied at checkout.", group: true) do |field| %>
<%= poetry_radio_group(name: "delivery", value: "standard",
**field.control_attributes.transform_keys(&:to_sym)) do |group| %>
<% group.with_item(value: "standard", label: "Standard (3-5 days)") %>
<% group.with_item(value: "express", label: "Express (next day)") %>
<% group.with_item(value: "pickup", label: "Store pickup") %>
<% end %>
<% end %>
</div>
With select
The owning team gets the notification.
<%# A Select as the control: control_attributes lands the id on the
trigger (the label-for target) and threads aria-describedby to it. %>
<div class="w-80">
<%= poetry_field(id: "field-team", label_text: "Assign to team",
hint: "The owning team gets the notification.") do |field| %>
<%= poetry_select(name: "team", placeholder: "Pick a team",
**field.control_attributes.transform_keys(&:to_sym)) do |select| %>
<% select.with_item(value: "engineering") { "Engineering" } %>
<% select.with_item(value: "design") { "Design" } %>
<% select.with_item(value: "support") { "Customer Support" } %>
<% select.with_item(value: "operations") { "Operations" } %>
<% end %>
<% end %>
</div>
With slider
Drag to set your spending cap.
<%# A Slider is role-bearing too (group: true). The single thumb takes its
accessible name from the field label via labelled_by:, and the hint
reaches the thumb through described_by: - the Field-wired slider recipe. %>
<div class="w-80">
<%= poetry_field(id: "field-budget", label_text: "Monthly budget",
hint: "Drag to set your spending cap.", group: true) do |field| %>
<%= poetry_slider(name: "budget", value: 40,
labelled_by: field.label_id, described_by: field.hint_id,
class: "w-full") %>
<% end %>
</div>
With textarea
Markdown is supported.
<%# The quartet hosts any control: the field owns the id and yields
control_attributes - splat them onto the textarea, never hand-wire
the label/aria pairing. %>
<div class="w-80">
<%= poetry_field(id: "field-summary", label_text: "Release notes",
hint: "Markdown is supported.") do |field| %>
<%= poetry_textarea(name: "summary",
placeholder: "What changed in this version?",
rows: 4,
**field.control_attributes.transform_keys(&:to_sym)) %>
<% end %>
</div>
API
Poetry::Ui::Field::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 |
|---|---|---|---|
| orientation: | Symbol | one of: vertical, horizontal, setting, responsive; defaults to :vertical |
The layout axis. :horizontal is the boolean-control pattern: the control lands in the first grid column, label + hint/error stack in the second, and the control row-centers against the label line. |
| error: | String | The error line (typically from model errors) - presence flips the invalid skin and leads the control's aria-describedby. | |
| group: | Boolean | defaults to false |
group: the control is a role-bearing <div> (RadioGroup, Slider) - label[for] would be inert (Chrome flags it), so the label drops for=, carries label_id, and control_attributes names the group via aria-labelledby (the visible label, i18n-proof). |
| hint: | String | Plain-text guidance under the control (escaped wholesale); use with_hint for authored markup. | |
| hint_position: | Symbol | defaults to :below |
Where the hint renders relative to the control - :above puts guidance before a tall control. aria-describedby is identical either way; this is visual order only. |
| id: | String | required | The control's DOM id - the hint/error/label ids derive from it. |
| invalid: | Boolean | defaults to false |
Flips the invalid skin (data-invalid + aria-invalid) WITHOUT an error line. error: implies it; use invalid: alone when the hint copy IS the requirement. |
| label_text: | String | The visible label text, associated with the control via for=. | |
| required: | Boolean | defaults to false |
Marks the control required via aria-required only - never the native required attribute. |
Methods
| Method | Description |
|---|---|
| #control_attributes | Everything the control inside the field must carry - the id, aria-describedby (error id first, then hint id), aria-invalid, aria-required, and aria-labelledby when group:. Merge it into the control's attributes (the FormBuilder does this for you). |
| #with_hint(&block) | Block-form hint for authored markup - a link or emphasis inside the guidance. Call it BEFORE the control renders, so the hint id lands in the control's aria-describedby; conflicts with hint: - use one or the other. The captured buffer renders as-is and is never re-blessed: ERB-authored markup stays markup, every interpolated value escapes normally, and a plain-String return is escaped by capture. Untrusted data belongs in hint: (escaped wholesale) or inside <%= %> in the block - never pre-marked html_safe. |
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=field] | The quartet's grid root - label, control, hint, and error stack inside |
| [data-slot=field-label] | The Label (composed) wearing the source's field-label slot - names the control |
| [data-slot=field-description] | The hint <p> (the source's description) - its id lands in the control's aria-describedby |
| [data-slot=field-error] | The error <p> - present only when error: is set; its id leads the control's aria-describedby |
| [data-slot=checkbox-input] | A nested Checkbox's hidden native input - the toggle renders as a wrapper-free fragment, so its sibling form store sits directly in the field's DOM (the horizontal boolean-control layout) |
| [data-slot=switch-input] | A nested Switch's hidden native input - the same wrapper-free fragment escape as checkbox-input (the setting-row layout) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| field | data-invalid | always - true when error: is present or invalid: is set, else false | true · false |
| field | data-orientation | always - the resolved orientation (horizontal is the boolean-control layout) | vertical · horizontal · setting · responsive |