docs/components/forms.md

# Form Components

All form components integrate with `Phoenix.HTML.Form` and Ecto changesets. They display inline validation errors, support `phx-debounce`, and follow WAI-ARIA labelling conventions.

- [Input](#input)
- [Textarea](#textarea)
- [Select](#select)
- [Form primitives](#form-primitives)
- [Tags Input](#tags-input)
- [Image Upload](#image-upload)
- [Rich Text Editor](#rich-text-editor)

---

## Input

Label + input + optional description + Ecto changeset errors in one component.

```heex
<.phia_input field={@form[:email]} type="email" label="Email address" />
<.phia_input field={@form[:name]} label="Full name" description="As it appears on your ID." />
<.phia_input field={@form[:password]} type="password" label="Password" />
<.phia_input field={@form[:amount]} type="number" label="Amount" placeholder="0.00" />
```

### With debounce

```heex
<.phia_input
  field={@form[:slug]}
  label="URL slug"
  description="Lowercase letters, hyphens only."
  phx-debounce="500"
/>
```

### Full sign-up form

```heex
<.form for={@form} phx-change="validate" phx-submit="register">
  <div class="space-y-4">
    <div class="grid grid-cols-2 gap-4">
      <.phia_input field={@form[:first_name]} label="First name" />
      <.phia_input field={@form[:last_name]} label="Last name" />
    </div>
    <.phia_input field={@form[:email]} type="email" label="Email" />
    <.phia_input field={@form[:password]} type="password" label="Password" />
    <.phia_input field={@form[:password_confirmation]} type="password" label="Confirm password" />
    <.button type="submit" class="w-full">Create account</.button>
  </div>
</.form>
```

### LiveView validate + submit pattern

```elixir
def handle_event("validate", %{"user" => params}, socket) do
  form = %User{} |> User.changeset(params) |> to_form(action: :validate)
  {:noreply, assign(socket, form: form)}
end

def handle_event("register", %{"user" => params}, socket) do
  case Accounts.create_user(params) do
    {:ok, user} -> {:noreply, push_navigate(socket, to: ~p"/dashboard")}
    {:error, cs} -> {:noreply, assign(socket, form: to_form(cs))}
  end
end
```

---

## Textarea

Multi-line form field with changeset error display.

```heex
<.phia_textarea field={@form[:bio]} label="Bio" rows={4} />
<.phia_textarea field={@form[:notes]} label="Notes" placeholder="Add any notes…" />
<.phia_textarea
  field={@form[:description]}
  label="Description"
  description="Max 500 characters."
  rows={6}
/>
```

---

## Select

Native `<select>` with FormField integration.

```heex
<.phia_select
  field={@form[:role]}
  options={["Admin", "Editor", "Viewer"]}
  label="Role"
  prompt="Choose a role…"
/>

<%!-- With keyword list for value/label pairs --%>
<.phia_select
  field={@form[:country]}
  options={[{"United States", "us"}, {"Brazil", "br"}, {"Portugal", "pt"}]}
  label="Country"
/>

<%!-- Loaded from database --%>
<.phia_select
  field={@form[:team_id]}
  options={Enum.map(@teams, &{&1.name, &1.id})}
  label="Team"
  prompt="Select team…"
/>
```

---

## Form Primitives

Low-level building blocks for custom form layouts.

```heex
<.form_field>
  <.form_label for="custom-input">Label text</.form_label>
  <input id="custom-input" type="text" class="…" />
  <.form_message>Validation message here.</.form_message>
</.form_field>
```

### Building a custom two-column row

```heex
<div class="grid grid-cols-2 gap-4">
  <.form_field>
    <.form_label for="city">City</.form_label>
    <input id="city" type="text" name="address[city]" class="phia-input" />
  </.form_field>
  <.form_field>
    <.form_label for="zip">ZIP Code</.form_label>
    <input id="zip" type="text" name="address[zip]" class="phia-input" />
  </.form_field>
</div>
```

---

## Tags Input

Multi-value input with deduplication. Tags are synced to a hidden CSV input for form submission.

```heex
<.tags_input
  field={@form[:tags]}
  label="Tags"
  placeholder="Type and press Enter…"
  separator=","
/>
```

### With initial values from assigns

```heex
<.tags_input
  field={@form[:skills]}
  label="Skills"
  placeholder="Add a skill…"
/>
```

The component reads the current value from `field.value` (a comma-separated string) and splits it into individual tag chips automatically.

### Requires PhiaTagsInput hook

```javascript
// assets/js/app.js
import PhiaTagsInput from "./phia_hooks/tags_input"
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { PhiaTagsInput }
})
```

### LiveView handler

```elixir
# Tags arrive as a comma-separated string in params
def handle_event("validate", %{"post" => %{"tags" => tags_csv}}, socket) do
  tags = String.split(tags_csv, ",", trim: true)
  # validate as normal
end
```

---

## Image Upload

Drop zone + file picker + preview grid. Uses native Phoenix LiveView uploads — no custom upload endpoint needed.

```heex
<.live_file_input upload={@uploads.avatar} class="sr-only" />
<.image_upload upload={@uploads.avatar} label="Profile photo" />
```

### Multiple file upload

```heex
<.live_file_input upload={@uploads.photos} class="sr-only" />
<.image_upload
  upload={@uploads.photos}
  label="Product photos"
  description="Up to 5 photos. JPG or PNG, max 5MB each."
/>
```

### LiveView setup

```elixir
def mount(_params, _session, socket) do
  {:ok,
   socket
   |> allow_upload(:avatar,
     accept: ~w(.jpg .jpeg .png .webp),
     max_entries: 1,
     max_file_size: 5_000_000
   )}
end

def handle_event("save", _params, socket) do
  urls =
    consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
      dest = Path.join([:code.priv_dir(:my_app), "static/uploads", Path.basename(path)])
      File.cp!(path, dest)
      {:ok, ~p"/uploads/#{Path.basename(dest)}"}
    end)

  {:noreply, update(socket, :uploaded_urls, &(&1 ++ urls))}
end
```

---

## Rich Text Editor

WYSIWYG editor using `contenteditable` + `document.execCommand()`. Zero npm dependencies.

```heex
<.rich_text_editor
  field={@form[:content]}
  label="Content"
  placeholder="Start writing…"
  min_height="200px"
/>
```

### With custom height for long-form content

```heex
<.rich_text_editor
  field={@form[:article_body]}
  label="Article body"
  placeholder="Write your article…"
  min_height="400px"
/>
```

### Toolbar commands

| Category | Commands |
|----------|----------|
| Text style | Bold, Italic, Underline, Strikethrough |
| Headings | H1, H2, H3, Paragraph |
| Lists | Bullet list, Ordered list |
| Blocks | Blockquote, Code block |
| Inline | Inline code, Insert link |

### Requires PhiaRichTextEditor hook

```javascript
import PhiaRichTextEditor from "./phia_hooks/rich_text_editor"
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { PhiaRichTextEditor }
})
```

### Reading HTML content in LiveView

```elixir
def handle_event("save", %{"post" => params}, socket) do
  # params["content"] contains sanitized HTML
  case Posts.create(params) do
    {:ok, post} -> {:noreply, push_navigate(socket, to: ~p"/posts/#{post}")}
    {:error, cs} -> {:noreply, assign(socket, form: to_form(cs))}
  end
end
```

---

## Checkbox

Native HTML checkbox styled with Tailwind, with `indeterminate` state support and FormField integration.

```heex
<%!-- Standalone --%>
<.checkbox id="agree" name="agree" />
<.checkbox id="agree" name="agree" checked={true} />
<.checkbox id="partials" name="partials" indeterminate={true} />
<.checkbox id="disabled" name="disabled" disabled={true} />

<%!-- With Field wrapper (no Ecto) --%>
<.field>
  <div class="flex items-center gap-2">
    <.checkbox id="terms" name="terms" phx-change="toggle-terms" />
    <.field_label for="terms" required={true}>
      I agree to the <a href="/terms" class="underline">Terms of Service</a>
    </.field_label>
  </div>
  <.field_message error={@terms_error} />
</.field>

<%!-- With FormField (Ecto changeset) --%>
<.form for={@form} phx-change="validate" phx-submit="save">
  <.form_checkbox
    field={@form[:newsletter]}
    label="Subscribe to newsletter"
  />
  <.form_checkbox
    field={@form[:terms_accepted]}
    label="I accept the terms"
    required
  />
  <.button type="submit">Register</.button>
</.form>

<%!-- Select all / indeterminate pattern --%>
<.field>
  <div class="flex items-center gap-2">
    <.checkbox
      id="select-all"
      indeterminate={@some_selected}
      checked={@all_selected}
      phx-click="toggle-all"
    />
    <.field_label for="select-all">Select All</.field_label>
  </div>
</.field>
<.field :for={item <- @items}>
  <div class="flex items-center gap-2">
    <.checkbox
      id={"item-#{item.id}"}
      name="items[]"
      value={item.id}
      checked={item.id in @selected_ids}
      phx-click="toggle-item"
      phx-value-id={item.id}
    />
    <.field_label for={"item-#{item.id}"}>{item.name}</.field_label>
  </div>
</.field>
```

### ARIA states

| State | `data-state` | `aria-checked` |
|-------|-------------|----------------|
| Unchecked | `"unchecked"` | `"false"` |
| Checked | `"checked"` | `"true"` |
| Indeterminate | `"indeterminate"` | `"mixed"` |

---

## Calendar

Server-rendered monthly calendar grid for date selection.

```heex
<%!-- Single mode --%>
<.calendar
  id="booking-cal"
  value={@selected_date}
  current_month={@current_month}
  on_change="pick-date"
/>

<%!-- With constraints --%>
<.calendar
  id="delivery-cal"
  value={@delivery_date}
  current_month={@current_month}
  min={Date.utc_today()}
  max={Date.add(Date.utc_today(), 30)}
  disabled_dates={@unavailable_dates}
  on_change="pick-delivery"
/>
```

```elixir
def handle_event("pick-date", %{"date" => iso}, socket) do
  {:noreply, assign(socket, selected_date: Date.from_iso8601!(iso))}
end

def handle_event("calendar-prev-month", %{"month" => iso}, socket) do
  {:noreply, assign(socket, current_month: Date.from_iso8601!(iso))}
end

def handle_event("calendar-next-month", %{"month" => iso}, socket) do
  {:noreply, assign(socket, current_month: Date.from_iso8601!(iso))}
end
```

### Hook registration

```javascript
import PhiaCalendar from "./phia_hooks/calendar"
```

← [Back to README](../../README.md)