PhiaUi.Components.Field (phia_ui v0.1.17)

Copy Markdown View Source

Standalone field layout primitives for composing form fields without Phoenix.HTML.FormField.

While PhiaUi.Components.Form (form_field/1, form_label/1, etc.) requires an Ecto changeset and a Phoenix.HTML.FormField struct, the primitives in this module accept plain strings and booleans. This makes them ideal wrappers for custom inputs — checkboxes, radio groups, switches, sliders, and other non-standard form controls — without needing a changeset.

Components

FunctionPurpose
field/1Wrapper <div> with space-y-2 vertical rhythm
field_label/1Styled <label> with optional required asterisk (*)
field_description/1Helper/hint text in text-muted-foreground
field_message/1Conditional error message in text-destructive, renders nothing when nil

When to use

Use field/1 and its sub-components when:

  • You are building a custom input that does not bind directly to a Phoenix form
  • You want consistent field layout (label → input → description → error) without changesets
  • You are composing standalone UI (e.g. wizard steps, modal forms without Ecto)
  • You want to display a manually-constructed error string rather than a changeset error

For Ecto changeset forms, prefer PhiaUi.Components.Form.form_field/1 instead, which reads errors directly from field.errors.

Basic composition

<.field>
  <.field_label for="agree" required={true}>Accept Terms</.field_label>
  <input id="agree" type="checkbox" />
  <.field_description>You must accept the terms to continue.</.field_description>
  <.field_message error={@terms_error} />
</.field>

Wrapping custom components

The field/1 wrapper works with any PhiaUI component:

<%!-- Wrapping a standalone radio group --%>
<.field>
  <.field_label>Notification frequency</.field_label>
  <.radio_group :let={g} value={@frequency} name="frequency" phx-change="set_frequency">
    <.radio_group_item value="realtime" label="Real-time" {g} />
    <.radio_group_item value="daily"    label="Daily digest" {g} />
    <.radio_group_item value="never"    label="Never" {g} />
  </.radio_group>
  <.field_description>How often would you like to receive email notifications?</.field_description>
  <.field_message error={@frequency_error} />
</.field>

<%!-- Wrapping a slider --%>
<.field>
  <.field_label for="volume" required={true}>Volume</.field_label>
  <.slider id="volume" name="volume" value={@volume} phx-change="set_volume" />
  <.field_message error={@volume_error} />
</.field>

Multi-field settings panel

<.field>
  <.field_label>Profile visibility</.field_label>
  <.field_description>Controls who can see your public profile page.</.field_description>
  <.toggle_group type="single" value={@visibility} phx-change="set_visibility">
    <.toggle_group_item value="public">Public</.toggle_group_item>
    <.toggle_group_item value="friends">Friends only</.toggle_group_item>
    <.toggle_group_item value="private">Private</.toggle_group_item>
  </.toggle_group>
  <.field_message error={@visibility_error} />
</.field>

Error handling pattern

field_message/1 renders nothing when :error is nil, so it is safe to always render it and conditionally populate the error from your LiveView assigns:

# In your LiveView:
validate_form(attrs) ->
  errors = %{email: "must be a valid email address"}
  assign(socket, email_error: errors[:email])  # nil when valid

# In your template — always renders, only shows when non-nil:
<.field_message error={@email_error} />

Dark mode

All sub-components use semantic Tailwind tokens (text-muted-foreground, text-destructive) that automatically adapt to dark mode via PhiaUI's @custom-variant dark configuration.

Summary

Functions

Renders a field wrapper <div> with space-y-2 vertical rhythm.

Renders helper text below a form field label.

Renders a styled form field label.

Renders a field error message when :error is not nil.

Functions

field(assigns)

Renders a field wrapper <div> with space-y-2 vertical rhythm.

This is the outermost container for a field layout. It provides consistent vertical spacing (space-y-2) between the label, input, description, and error message sub-components.

Examples

<%!-- Minimal wrapper --%>
<.field>
  <.field_label for="email">Email</.field_label>
  <input id="email" type="email" class="..." />
</.field>

<%!-- Full field with all sub-components --%>
<.field>
  <.field_label for="email" required={true}>Email</.field_label>
  <input id="email" type="email" class="..." />
  <.field_description>We'll never share your email.</.field_description>
  <.field_message error={@email_error} />
</.field>

<%!-- Custom spacing override --%>
<.field class="mt-6">
  <.field_label for="bio">Bio</.field_label>
  <textarea id="bio" class="..." />
</.field>

Attributes

  • class (:string) - Additional CSS classes merged into the wrapper <div> via cn/1. Use to adjust spacing or layout, e.g. class="mt-4" or class="col-span-2".

    Defaults to nil.

  • Global attributes are accepted. Any HTML attributes forwarded to the wrapper <div> (e.g. id, data-*).

Slots

  • inner_block (required) - The field's sub-components in order: label → input → description → message. All four are optional — only include what the field needs.

field_description(assigns)

Renders helper text below a form field label.

Displayed in text-muted-foreground to visually distinguish it from the label (which uses full foreground colour) and from error messages (which use destructive colour). Use it for format hints, character limits, privacy notes, or constraints.

Examples

<%!-- Format hint --%>
<.field_description>Date format: YYYY-MM-DD</.field_description>

<%!-- Privacy note --%>
<.field_description>We'll never share your email with anyone else.</.field_description>

<%!-- Constraint --%>
<.field_description>Must be at least 8 characters, including a number.</.field_description>

<%!-- With a link --%>
<.field_description>
  By continuing you agree to our <a href="/terms" class="underline">Terms of Service</a>.
</.field_description>

Attributes

  • class (:string) - Additional CSS classes merged into the description <p> element via cn/1. Defaults to nil.
  • Global attributes are accepted. Any HTML attributes forwarded to the <p> element.

Slots

  • inner_block (required) - The description text. Can include links or inline formatting. Keep it concise — ideally one sentence that helps the user understand what to enter.

field_label(assigns)

Renders a styled form field label.

Applies text-sm font-medium leading-none with a disabled-state variant (peer-disabled:cursor-not-allowed peer-disabled:opacity-70) for when the associated input is disabled.

When :required is true, appends a red asterisk with aria-hidden="true" so screen readers do not read it redundantly. The required constraint should also be set on the input element itself (required attr) for browser validation.

Examples

<%!-- Basic label --%>
<.field_label for="email">Email address</.field_label>

<%!-- Required field indicator --%>
<.field_label for="password" required={true}>Password</.field_label>

<%!-- Without `for` (implicit association via wrapping) --%>
<label>
  <.field_label>Username</.field_label>
  <input type="text" />
</label>

<%!-- With extra styling --%>
<.field_label for="name" class="text-base">Full name</.field_label>

Attributes

  • for (:string) - The id of the associated form element. When set, the browser focuses the input when the label is clicked. Omit only when wrapping the input in the label directly (implicit association).

    Defaults to nil.

  • required (:boolean) - When true, appends a red asterisk (*) after the label text with aria-hidden="true". The asterisk is hidden from screen readers because the required constraint should be communicated via required on the input itself, not by reading "asterisk" aloud.

    Defaults to false.

  • class (:string) - Additional CSS classes merged into the <label> element via cn/1. Defaults to nil.

  • Global attributes are accepted. Any HTML attributes forwarded to the <label> element.

Slots

  • inner_block (required) - The label text. Can include inline elements like <abbr> for tooltips.

field_message(assigns)

Renders a field error message when :error is not nil.

Displays nothing (no DOM element) when error is nil, so it is safe to always include this component and conditionally populate :error from your LiveView assigns. This avoids if expressions in the template.

The error <p> renders in text-sm font-medium text-destructive. For accessibility, consider adding an id attribute and referencing it from the input via aria-describedby:

<.field_message id="email-error" error={@email_error} />
<input aria-describedby="email-error" ... />

Examples

<%!-- Always rendered, shows only when error is non-nil --%>
<.field_message error={@form_error} />

<%!-- With id for aria-describedby association --%>
<.field_message id="password-error" error={@password_error} />

<%!-- Explicit nil  renders nothing --%>
<.field_message error={nil} />

<%!-- Custom class --%>
<.field_message error={@error} class="mt-2 text-xs" />

Attributes

  • error (:any) - Error message string to display, or nil to render nothing. When non-nil, renders a <p> in text-destructive. Safe to always render with a potentially nil value — no wrapper if expression needed in the template.

    Defaults to nil.

  • class (:string) - Additional CSS classes merged into the error <p> element via cn/1. Defaults to nil.

  • Global attributes are accepted. Any HTML attributes forwarded to the <p> element (e.g. id for aria-describedby).