Current section
Files
Jump to
Current section
Files
lib/skua/components/form.ex
defmodule Skua.Components.Form do
@moduledoc """
Skua form components — button, label, error, field wrapper, input, textarea,
toggle (checkbox / radio / switch), OTP, chip toggle, segmented control and
slider (single + dual-handle range).
Every input accepts `field={@form[:name]}` as the primary API and falls back
to bare `name`/`value`. Errors surface only after the field is used, are
linked to the control with `aria-describedby`, and unchecked checkboxes emit
a hidden `false` companion so `phx-change` params never silently drop.
These components carry **no JavaScript** — they are real form elements styled
by `assets/css/skua.css` and driven entirely by server state.
"""
use Phoenix.Component
alias Skua.Field
@doc """
A button.
<.button>Save</.button>
<.button variant="primary" type="submit">Create account</.button>
<.button variant="danger" phx-click="delete">Delete</.button>
"""
attr :variant, :string, default: "secondary", values: ~w(primary secondary ghost danger)
attr :type, :string, default: "button"
attr :size, :string, default: nil, values: [nil, "sm", "md", "lg"]
attr :icon_only, :boolean, default: false, doc: "square icon button; provide an aria-label"
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled form name value aria-label)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
type={@type}
class={[
"sk-btn sk-btn--#{@variant} sk-focusable",
@size && "sk-#{@size}",
@icon_only && "sk-btn--icon",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
@doc "A field label. Pass `for` to associate it with a control id."
attr :for, :string, default: nil
attr :required, :boolean, default: false
attr :class, :any, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for} class={["sk-label", @class]}>
{render_slot(@inner_block)}<span :if={@required} class="sk-req" aria-hidden="true">*</span>
</label>
"""
end
@doc """
Inline error message(s) for a field. Rendered with an `id` so the control can
reference it via `aria-describedby`.
"""
attr :id, :string, default: nil
attr :errors, :list, default: []
def error(assigns) do
~H"""
<p :for={msg <- @errors} id={@id} class="sk-error">
<svg class="sk-glyph" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" />
</svg>{msg}
</p>
"""
end
@doc """
A labelled text input. Drive it with a form field:
<.input field={@form[:email]} type="email" label="Work email" required />
or bare attributes:
<.input name="q" value={@q} placeholder="Search…" />
Polymorphic, like `CoreComponents.input/1`: `type="checkbox"`, `"select"`
(pass `options`), `"textarea"`, and `"hidden"` render the matching Skua
control, so generated `phx.gen.auth`/`phx.gen.live` forms work unchanged and
look Skua-styled.
<.input field={@form[:role]} type="select" options={[{"Admin", "admin"}]} prompt="Pick…" />
<.input field={@form[:agree]} type="checkbox" label="I agree" />
<.input field={@form[:bio]} type="textarea" rows={4} label="Bio" />
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :any, default: nil
attr :type, :string, default: "text"
attr :label, :string, default: nil
attr :placeholder, :string, default: nil
attr :hint, :string, default: nil
attr :errors, :list, default: nil
attr :required, :boolean, default: false
attr :checked, :boolean, default: nil, doc: "checkbox: explicit checked state"
attr :options, :list,
default: [],
doc: "select: options (any Phoenix options_for_select format)"
attr :multiple, :boolean, default: false, doc: "select: allow multiple"
attr :prompt, :string, default: nil, doc: "select: leading empty prompt option"
attr :rows, :integer, default: nil, doc: "textarea: rows"
attr :class, :any, default: nil
attr :rest, :global, include: ~w(autocomplete inputmode min max step pattern readonly disabled)
slot :leading, doc: "icon/affix before the input"
slot :trailing, doc: "icon/affix after the input"
def input(%{type: "checkbox"} = assigns) do
assigns =
assigns
|> assign(:errors, Field.display_errors(assigns))
|> assign_new(:id, fn -> assigns[:field] && Field.sanitize_id(assigns.field.id) end)
~H"""
<div class="sk-field">
<.toggle
type="checkbox"
field={@field}
name={@name}
value={@value || "true"}
checked={@checked}
label={@label}
{@rest}
/>
<span :if={@errors != []} class="sk-msg"><.error errors={@errors} /></span>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<.textarea
field={@field}
id={@id}
name={@name}
value={@value}
label={@label}
hint={@hint}
errors={@errors}
required={@required}
rows={@rows}
{@rest}
/>
"""
end
def input(%{type: "hidden"} = assigns) do
assigns = Field.normalize(assigns)
~H"""
<input
type="hidden"
id={@id}
name={@name}
value={Phoenix.HTML.Form.normalize_value("hidden", @value)}
{@rest}
/>
"""
end
def input(%{type: "select"} = assigns) do
# Delegate to the real Skua select so it's fully Skua-styled (token-driven
# listbox), not a styled-native hybrid. `prompt` maps to the placeholder.
assigns = assign(assigns, :options, normalize_options(assigns.options))
~H"""
<Skua.Components.Select.select
field={@field}
id={@id}
name={@name}
value={@value}
options={@options}
multiple={@multiple}
prompt={@prompt}
label={@label}
hint={@hint}
errors={@errors}
required={@required}
placeholder={@prompt || @placeholder || "Select…"}
{@rest}
/>
"""
end
def input(assigns) do
assigns = Field.normalize(assigns)
assigns = assign_new(assigns, :describedby, fn -> describedby(assigns) end)
assigns = assign(assigns, :affixed?, assigns.leading != [] or assigns.trailing != [])
~H"""
<div class="sk-field">
<.label :if={@label} for={@id} required={@required}>{@label}</.label>
<%= if @affixed? do %>
<div class={["sk-input sk-focusable", @errors != [] && "is-invalid", @class]}>
<span :if={@leading != []} class="sk-affix">{render_slot(@leading)}</span>
<input
id={@id}
name={@name}
type={@type}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
placeholder={@placeholder}
required={@required}
aria-invalid={(@errors != [] && "true") || nil}
aria-describedby={@describedby}
{@rest}
/>
<span :if={@trailing != []} class="sk-affix">{render_slot(@trailing)}</span>
</div>
<% else %>
<input
id={@id}
name={@name}
type={@type}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
placeholder={@placeholder}
required={@required}
aria-invalid={(@errors != [] && "true") || nil}
aria-describedby={@describedby}
class={["sk-input sk-focusable", @errors != [] && "is-invalid", @class]}
{@rest}
/>
<% end %>
<span class="sk-msg">
<.error :if={@errors != []} id={"#{@id}-error"} errors={@errors} />
<span :if={@hint && @errors == []} id={"#{@id}-hint"} class="sk-help">{@hint}</span>
</span>
</div>
"""
end
@doc "A labelled multi-line textarea. Same field API as `input/1`."
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :any, default: nil
attr :label, :string, default: nil
attr :placeholder, :string, default: nil
attr :hint, :string, default: nil
attr :errors, :list, default: nil
attr :required, :boolean, default: false
attr :rows, :integer, default: nil
attr :class, :any, default: nil
attr :rest, :global, include: ~w(readonly disabled maxlength minlength)
def textarea(assigns) do
assigns = Field.normalize(assigns)
assigns = assign_new(assigns, :describedby, fn -> describedby(assigns) end)
~H"""
<div class="sk-field">
<.label :if={@label} for={@id} required={@required}>{@label}</.label>
<textarea
id={@id}
name={@name}
rows={@rows}
placeholder={@placeholder}
required={@required}
aria-invalid={(@errors != [] && "true") || nil}
aria-describedby={@describedby}
class={["sk-input sk-focusable", @errors != [] && "is-invalid", @class]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
<span class="sk-msg">
<.error :if={@errors != []} id={"#{@id}-error"} errors={@errors} />
<span :if={@hint && @errors == []} id={"#{@id}-hint"} class="sk-help">{@hint}</span>
</span>
</div>
"""
end
@doc """
A checkbox, radio, or switch — a real, **keyboard-operable** form input with
a CSS-drawn visual that reacts to native `:checked`/`:focus-visible` (no JS).
<.toggle type="switch" field={@form[:dark]} label="Dark mode" />
<.toggle type="checkbox" name="tos" label="I agree" />
Checkboxes emit a hidden `false` companion so deselection is never dropped
from `phx-change` params.
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :type, :string, default: "checkbox", values: ~w(checkbox radio switch)
attr :value, :string, default: "true", doc: "the value submitted when checked"
attr :checked, :boolean, default: nil
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled required)
def toggle(assigns) do
# A toggle's submit `value` (e.g. "true") is distinct from the field's
# current value (the checked state), so we derive id/name from the field
# but compute `checked` ourselves rather than letting normalize clobber it.
checked = toggle_checked(assigns)
field = assigns[:field]
# attr defaults populate :id/:name as nil, so assign_new would no-op —
# derive from the field explicitly, letting explicit values win.
assigns =
assigns
|> assign(:checked, checked)
|> assign(:id, assigns[:id] || (field && Field.sanitize_id(field.id)))
|> assign(:name, assigns[:name] || (field && field.name))
~H"""
<label class={["sk-opt-row", @class]}>
<input
:if={@type == "checkbox"}
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
/>
<input
id={@id}
type={(@type == "radio" && "radio") || "checkbox"}
name={@name}
value={@value}
checked={@checked}
class="sk-opt-input sk-focusable"
{@rest}
/>
<span :if={@type == "checkbox"} class="sk-check" aria-hidden="true">
<svg class="sk-tick" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span :if={@type == "radio"} class="sk-radio" aria-hidden="true"></span>
<span :if={@type == "switch"} class="sk-switch" aria-hidden="true">
<span class="sk-thumb"></span>
</span>
<span :if={@label} class="sk-opt-label">{@label}</span>
</label>
"""
end
@doc """
A segmented code / OTP input. A hidden `<input>` carries the combined value,
so `phx-change`/`phx-submit` work.
<.otp_input field={@form[:code]} length={6} group={3} />
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :any, default: nil
attr :length, :integer, default: 6
attr :mode, :string, default: "numeric", values: ~w(numeric text)
attr :group, :integer, default: nil, doc: "insert a separator every N cells"
attr :label, :string, default: nil
attr :errors, :list, default: nil
def otp_input(assigns) do
assigns =
assigns
|> Field.normalize()
|> then(fn a -> assign(a, :id, a.id || "sk-otp") end)
~H"""
<div class="sk-field">
<.label :if={@label} for={@id}>{@label}</.label>
<div
id={@id}
class={["sk-otp", @errors != [] && "is-invalid"]}
phx-hook="SkuaOtp"
phx-update="ignore"
data-len={@length}
data-mode={@mode}
>
<input type="hidden" name={@name} value={@value} data-sk-otp-value />
<%= for i <- 0..(@length - 1) do %>
<span :if={@group && i > 0 && rem(i, @group) == 0} class="sk-otp-gap"></span>
<input class="sk-otp-cell sk-focusable" placeholder="0" aria-label={"digit #{i + 1}"} />
<% end %>
</div>
<span class="sk-msg">
<.error :if={@errors != []} id={"#{@id}-error"} errors={@errors} />
</span>
</div>
"""
end
@doc """
A checkbox styled as a chip — for multi-pick chip groups. Real checkboxes, so
they submit under one array name.
<.chip_toggle :for={p <- @perms} field={@form[:perms]} value={p} label={p} />
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :name, :string, default: nil
attr :value, :string, required: true
attr :checked, :boolean, default: nil
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled)
slot :inner_block
def chip_toggle(assigns) do
field = assigns[:field]
checked = chip_checked(assigns)
assigns =
assigns
|> assign(:checked, checked)
|> assign(:name, assigns[:name] || (field && "#{field.name}[]"))
~H"""
<label class={["sk-chip sk-chip--toggle sk-focusable", @class]}>
<input
type="checkbox"
name={@name}
value={@value}
checked={@checked}
class="sk-opt-input"
{@rest}
/>
<svg class="sk-glyph sk-chip-tick" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
{@label}{render_slot(@inner_block)}
</label>
"""
end
@doc """
A single-select **segmented control** — real radio inputs, so it submits and
works with `phx-change` natively (no JavaScript).
<.segmented field={@form[:view]} options={["List", "Board", "Calendar"]} />
<.segmented field={@form[:plan]} options={[{"Monthly", "mo"}, {"Yearly", "yr"}]} label="Billing" />
Options take any `{label, value}` / bare-value form, like `select/1`. The
selected segment comes from the field value (or an explicit `value`).
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :any, default: nil
attr :options, :list, required: true
attr :label, :string, default: nil
attr :errors, :list, default: nil
attr :class, :any, default: nil
attr :rest, :global, include: ~w(disabled)
def segmented(assigns) do
field = assigns[:field]
selected = assigns[:value] || (field && field.value)
name = assigns[:name] || (field && field.name)
assigns =
assigns
|> assign(:name, name)
|> assign(:id, assigns[:id] || (field && Field.sanitize_id(field.id)) || fallback_id("sk-seg", name))
|> assign(:selected, selected)
|> assign(:options, seg_options(assigns.options))
|> assign(:errors, Field.display_errors(assigns))
~H"""
<div class="sk-field">
<span :if={@label} id={"#{@id}-label"} class="sk-label">{@label}</span>
<div
class={["sk-segmented", @class]}
role="radiogroup"
aria-labelledby={(@label && "#{@id}-label") || nil}
id={@id}
>
<label :for={{label, value} <- @options} class="sk-segment">
<input
type="radio"
name={@name}
value={value}
checked={to_string(value) == to_string(@selected)}
class="sk-segment-input sk-focusable"
{@rest}
/>
<span class="sk-segment-label">{label}</span>
</label>
</div>
<span :if={@errors != []} class="sk-msg"><.error errors={@errors} /></span>
</div>
"""
end
@doc """
A slider. Single-handle by default; pass `range` for a **two-handle** range
with independently draggable thumbs. Drag with the mouse/touch or operate with
the keyboard (←/→/↑/↓, PageUp/Down, Home/End); ARIA `slider` roles throughout.
Values post through hidden inputs so `phx-change`/submit work with no JS glue.
<.slider field={@form[:volume]} min={0} max={100} value={40} label="Volume" />
<.slider name="price" range value={[20, 80]} min={0} max={100} step={5} label="Price" />
Single posts under `name`; range posts under `name[min]` / `name[max]`.
"""
attr :field, Phoenix.HTML.FormField, default: nil
attr :id, :string, default: nil
attr :name, :string, default: nil
attr :value, :any, default: nil, doc: "single: a number; range: a [lo, hi] list"
attr :min, :integer, default: 0
attr :max, :integer, default: 100
attr :step, :integer, default: 1
attr :range, :boolean, default: false
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
def slider(assigns) do
field = assigns[:field]
raw = assigns[:value] || (field && field.value)
{lo, hi} = slider_values(raw, assigns.range, assigns.min, assigns.max)
name = assigns[:name] || (field && field.name)
assigns =
assigns
|> assign(:name, name)
|> assign(:id, assigns[:id] || (field && Field.sanitize_id(field.id)) || fallback_id("sk-slider", name))
|> assign(:lo, lo)
|> assign(:hi, hi)
~H"""
<div class="sk-field">
<span :if={@label} id={"#{@id}-label"} class="sk-label">{@label}</span>
<div
class={["sk-slider", @range && "sk-slider--range", @class]}
id={@id}
phx-hook="SkuaSlider"
phx-update="ignore"
data-min={@min}
data-max={@max}
data-step={@step}
data-range={to_string(@range)}
aria-labelledby={(@label && "#{@id}-label") || nil}
{@rest}
>
<input :if={@range} type="hidden" name={"#{@name}[min]"} value={@lo} data-sk-lo />
<input type="hidden" name={(@range && "#{@name}[max]") || @name} value={@hi} data-sk-hi />
<div class="sk-slider-track" data-sk-track>
<div class="sk-slider-fill" data-sk-fill></div>
<button
:if={@range}
type="button"
class="sk-slider-thumb sk-focusable"
data-sk-thumb="lo"
role="slider"
aria-valuemin={@min}
aria-valuemax={@max}
aria-valuenow={@lo}
aria-label="Minimum"
>
</button>
<button
type="button"
class="sk-slider-thumb sk-focusable"
data-sk-thumb="hi"
role="slider"
aria-valuemin={@min}
aria-valuemax={@max}
aria-valuenow={@hi}
aria-label={(@range && "Maximum") || "Value"}
>
</button>
</div>
</div>
</div>
"""
end
# Coerce CoreComponents-style option lists into Skua's {label, value} tuples.
defp normalize_options(options) do
Enum.map(options, fn
{label, value, desc} -> {label, value, desc}
{label, value} -> {label, value}
value -> {to_string(value), value}
end)
end
# Segmented control needs strictly {label, value} pairs (no description tuples).
defp seg_options(options) do
Enum.map(options, fn
{label, value, _desc} -> {label, value}
{label, value} -> {label, value}
value -> {to_string(value), value}
end)
end
# Resolve a slider's value(s) into a {lo, hi} integer pair. Single sliders
# only use `hi` (the lone thumb); range sliders use both.
defp slider_values(raw, true, min, max) do
case raw do
[lo, hi] -> {to_int(lo, min), to_int(hi, max)}
%{"min" => lo, "max" => hi} -> {to_int(lo, min), to_int(hi, max)}
%{min: lo, max: hi} -> {to_int(lo, min), to_int(hi, max)}
_ -> {min, max}
end
end
defp slider_values(raw, _single, min, _max), do: {min, to_int(raw, min)}
defp to_int(v, _default) when is_integer(v), do: v
defp to_int(v, _default) when is_float(v), do: round(v)
defp to_int(v, default) when is_binary(v) do
case Integer.parse(v) do
{n, _} -> n
:error -> default
end
end
defp to_int(_v, default), do: default
# A stable, unique-ish fallback id derived from the field name (so two of the
# same control on one page don't collide when neither sets an explicit id).
defp fallback_id(prefix, nil), do: prefix
defp fallback_id(prefix, name), do: prefix <> "-" <> String.replace(to_string(name), ~r/[^A-Za-z0-9_-]/, "-")
# Points aria-describedby at whichever message is actually rendered.
defp describedby(%{errors: errors, id: id}) when errors != [], do: "#{id}-error"
defp describedby(%{hint: hint, id: id}) when not is_nil(hint), do: "#{id}-hint"
defp describedby(_), do: nil
# Explicit `checked` wins; otherwise derive from the field value. Radios
# compare against the submit value; checkboxes/switches read truthiness.
defp toggle_checked(%{checked: checked}) when is_boolean(checked), do: checked
defp toggle_checked(%{field: %Phoenix.HTML.FormField{value: value}, type: "radio", value: v}) do
to_string(value) == to_string(v)
end
defp toggle_checked(%{field: %Phoenix.HTML.FormField{value: value}}) do
Phoenix.HTML.Form.normalize_value("checkbox", value)
end
defp toggle_checked(_), do: false
# A chip is checked when its value is among the field's (array) value.
defp chip_checked(%{checked: c}) when is_boolean(c), do: c
defp chip_checked(%{field: %Phoenix.HTML.FormField{value: value}, value: v}) do
to_string(v) in Enum.map(List.wrap(value), &to_string/1)
end
defp chip_checked(_), do: false
end