# Composition Patterns

Recipes for common UI patterns built from Plushie's built-in widgets.
Each pattern shows complete code with the view and update logic. These
are not special framework features. They are compositions of existing
widgets and state helpers.

| Section | Patterns |
|---|---|
| [Navigation](#navigation) | Tabs, sidebar, breadcrumbs, route dispatch |
| [Overlays](#overlays) | Modal dialog, toast notifications, popover, loading indicator |
| [Layout](#layout-patterns) | Toolbar, cards, split panel, badges |
| [Forms](#form-patterns) | Validated fields, search and filter |
| [State helpers](#state-helper-patterns) | Selection, undo, data query with sort |
| [Interaction](#interaction-patterns) | Debounced search, context menu, keyboard shortcuts, focus management, multi-window |

## Navigation

### Tabs

Buttons in a row with conditional content. Track the active tab in the
model.

```elixir
def view(model) do
  tabs = [:overview, :details, :settings]

  window "main", title: "Tabs" do
    column width: :fill do
      row spacing: 0 do
        for tab <- tabs do
          button(
            "tab:#{tab}",
            tab |> Atom.to_string() |> String.capitalize(),
            style: if(model.active_tab == tab, do: :primary, else: :text)
          )
        end
      end

      rule()

      case model.active_tab do
        :overview -> overview_content(model)
        :details -> details_content(model)
        :settings -> settings_content(model)
      end
    end
  end
end

def update(model, %WidgetEvent{type: :click, id: "tab:" <> tab_name}) do
  %{model | active_tab: String.to_existing_atom(tab_name)}
end
```

### Sidebar navigation

A fixed-width column alongside the main content. The sidebar has a
fixed pixel width; the content area fills the rest.

```elixir
row width: :fill, height: :fill do
  column width: 200, height: :fill, padding: 8, spacing: 4 do
    for item <- nav_items do
      button(item.id, item.label,
        width: :fill,
        style: if(item.id == model.active, do: :primary, else: :text)
      )
    end
  end

  container "content", width: :fill, height: :fill, padding: 16 do
    render_active_page(model)
  end
end
```

### Breadcrumbs

Interleaved buttons and separators. The last segment is plain text
(current location); earlier segments are clickable for navigation.

```elixir
row spacing: 4 do
  for {segment, i} <- Enum.with_index(model.breadcrumbs) do
    if i > 0 do
      text("sep-#{i}", "/", size: 12, color: "#999")
    end

    if i == length(model.breadcrumbs) - 1 do
      text("crumb-#{i}", segment, size: 12)
    else
      button("crumb-#{i}", segment, style: :text)
    end
  end
end
```

### Route-based view dispatch

Use `Plushie.Route` for a navigation stack with back/forward and
per-route parameters:

```elixir
def view(model) do
  window "main", title: "App" do
    case Route.current(model.route) do
      :list -> list_view(model)
      :detail -> detail_view(model, Route.params(model.route))
      :settings -> settings_view(model)
    end
  end
end

def update(model, %WidgetEvent{type: :click, id: "show-detail", scope: [item_id | _]}) do
  %{model | route: Route.push(model.route, :detail, %{id: item_id})}
end

def update(model, %WidgetEvent{type: :click, id: "back"}) do
  %{model | route: Route.pop(model.route)}
end
```

## Overlays

Overlays (modals, toasts, popovers) must be placed at the **window
level** to stay visible regardless of scrolling. A `stack` as the
direct child of the window layers overlay content on top of everything
else. If you put an overlay inside a scrollable or a fixed-height
container, it scrolls or clips with that container.

```elixir
def view(model) do
  window "main", title: "App" do
    stack width: :fill, height: :fill do
      # Layer 0: all app content (may scroll internally)
      main_content(model)

      # Layer 1: modal (covers full window when active)
      if model.show_modal, do: modal_overlay(model)

      # Layer 2: toasts (topmost, visible even over modals)
      toast_overlay(model)
    end
  end
end
```

### Modal dialog

The modal overlay covers the full window with a semi-transparent
backdrop and centres the dialog. The `if` without `else` returns `nil`,
which `stack` filters out.

```elixir
defp modal_overlay(model) do
  container "overlay",
    width: :fill, height: :fill,
    background: "#00000088",
    center: true do

    container "dialog", padding: 24, background: "#ffffff",
      border: Border.new() |> Border.rounded(8) do
      column spacing: 12 do
        text("modal-title", "Confirm", size: 18)
        text("modal-body", "Are you sure?")
        row spacing: 8 do
          button("modal-cancel", "Cancel")
          button("modal-confirm", "Confirm", style: :primary)
        end
      end
    end
  end
end

def update(model, %WidgetEvent{type: :click, id: "delete"}) do
  %{model | show_modal: true, pending_action: :delete}
end

def update(model, %WidgetEvent{type: :click, id: "modal-confirm"}) do
  model = perform_action(model, model.pending_action)
  %{model | show_modal: false, pending_action: nil}
end

def update(model, %WidgetEvent{type: :click, id: "modal-cancel"}) do
  %{model | show_modal: false, pending_action: nil}
end
```

### Toast notifications

Toasts are transient messages that auto-dismiss after a timeout. Place
them at the window level in a `stack` so they stay visible regardless
of scroll position. Use `Command.send_after` for auto-dismiss and
`Command.announce` for screen reader announcements.

```elixir
# Model: toasts is a list of %{id: string, text: string, level: atom}

defp toast_overlay(model) do
  if model.toasts != [] do
    container width: :fill, height: :fill, align_x: :right, align_y: :bottom do
      column spacing: 8, padding: 16 do
        for toast <- model.toasts do
          container toast.id, padding: {8, 16},
            background: toast_color(toast.level),
            border: Border.new() |> Border.rounded(6),
            opacity: transition(200, to: 1.0, from: 0.0) do
            row spacing: 8 do
              text(toast.id <> "-msg", toast.text, color: "#fff")
              button(toast.id <> "-dismiss", "x", style: :text)
            end
          end
        end
      end
    end
  end
end

defp toast_color(:error), do: "#ef4444"
defp toast_color(:success), do: "#22c55e"
defp toast_color(_), do: "#333333"

# Show a toast with auto-dismiss:
defp show_toast(model, text, level \\ :info) do
  id = "toast-#{:erlang.unique_integer([:positive])}"
  toast = %{id: id, text: text, level: level}
  model = %{model | toasts: model.toasts ++ [toast]}

  # Auto-dismiss after 5 seconds; announce for screen readers
  {model, Command.batch([
    Command.send_after(5000, {:dismiss_toast, id}),
    Command.announce(text)
  ])}
end

def update(model, {:dismiss_toast, id}) do
  %{model | toasts: Enum.reject(model.toasts, &(&1.id == id))}
end

def update(model, %WidgetEvent{type: :click, id: id, scope: [toast_id | _]})
    when id in ["-dismiss"] do
  %{model | toasts: Enum.reject(model.toasts, &(&1.id == toast_id))}
end
```

### Popover

The `overlay` widget positions floating content relative to an anchor.
First child is the anchor, second is the overlay. Exactly two children
required.

```elixir
overlay "menu", position: :below, gap: 4, flip: true do
  button("trigger", "Options")

  container "dropdown", padding: 8, background: "#fff",
    border: Border.new() |> Border.width(1) |> Border.color("#ddd") |> Border.rounded(4) do
    column spacing: 2 do
      button("opt-edit", "Edit", style: :text, width: :fill)
      button("opt-delete", "Delete", style: :text, width: :fill)
    end
  end
end
```

`flip: true` auto-repositions when the overlay would go off-screen.

### Loading indicator

Conditionally show a loading overlay while async work is in flight.
Like modals, place this at the window level so it covers scrollable
content:

```elixir
# Inside the window-level stack:
if model.loading do
  container width: :fill, height: :fill, background: "#ffffff88", center: true do
    text("loading", "Loading...", size: 16)
  end
end

def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
  {%{model | loading: true}, Command.async(fn -> fetch_data() end, :fetch)}
end

def update(model, %AsyncEvent{tag: :fetch, result: {:ok, data}}) do
  %{model | loading: false, data: data}
end
```

## Layout patterns

### Toolbar

A row with button groups, separators, and trailing alignment.
`space(width: :fill)` pushes everything after it to the right.

```elixir
row spacing: 4, padding: {4, 8} do
  button("bold", "B")
  button("italic", "I")

  rule(direction: :vertical, height: 20)

  button("align-left", "Left")
  button("align-center", "Center")

  space(width: :fill)

  button("settings", "Settings")
end
```

### Cards

A reusable card helper with border, shadow, and padding:

```elixir
defp card(id, do: block) do
  container id,
    padding: 16,
    background: "#ffffff",
    border: Border.new() |> Border.color("#e5e7eb") |> Border.width(1) |> Border.rounded(8),
    shadow: Shadow.new() |> Shadow.color("#0000001a") |> Shadow.offset(0, 2) |> Shadow.blur_radius(4) do
    block
  end
end

# Usage:
card "user-info" do
  column spacing: 8 do
    text("name", model.user.name, size: 16)
    text("email", model.user.email, color: "#666")
  end
end
```

### Split panel

A resizable divider between two panes. Use `pointer_area` for drag
tracking and a subscription for mouse move during drag:

```elixir
row width: :fill, height: :fill do
  container "left", width: model.split_width, height: :fill do
    left_content(model)
  end

  pointer_area "divider",
    cursor: :resizing_horizontally,
    on_press: "drag-start",
    on_release: "drag-end" do
    container width: 4, height: :fill, background: "#ddd" do
    end
  end

  container "right", width: :fill, height: :fill do
    right_content(model)
  end
end

def subscribe(model) do
  if model.dragging do
    [Plushie.Subscription.on_pointer_move(:drag, max_rate: 60)]
  else
    []
  end
end

def update(model, %WidgetEvent{type: :click, id: "drag-start"}), do: %{model | dragging: true}
def update(model, %WidgetEvent{type: :click, id: "drag-end"}), do: %{model | dragging: false}
def update(model, %WidgetEvent{type: :move, data: %{x: x}}), do: %{model | split_width: max(100, x)}
```

### Badges and chips

Pills with large border radius. `rounded(999)` clamps to the maximum,
creating a pill shape.

```elixir
container "badge",
  padding: {2, 8},
  background: "#3b82f6",
  border: Border.new() |> Border.rounded(999) do
  text("count", "#{model.unread}", color: "#fff", size: 12)
end
```

## Form patterns

### Validated form field

Show inline validation errors below the input:

```elixir
column spacing: 4 do
  text_input("email", model.email, placeholder: "Email",
    a11y: %{required: true, invalid: model.email_error != nil})

  if model.email_error do
    text("email-error", model.email_error, color: "#ef4444", size: 12)
  end
end

def update(model, %WidgetEvent{type: :input, id: "email", value: email}) do
  error = if String.contains?(email, "@"), do: nil, else: "Must be a valid email"
  %{model | email: email, email_error: error}
end
```

### Search and filter

A text input that filters a list in real time using `Plushie.Data`:

```elixir
column spacing: 8 do
  text_input("search", model.query, placeholder: "Search...")

  keyed_column spacing: 4 do
    for item <- filtered_items(model) do
      text(item.id, item.name)
    end
  end
end

defp filtered_items(model) do
  if model.query == "" do
    model.items
  else
    Data.query(model.items, search: {[:name], model.query}).entries
  end
end
```

## State helper patterns

### Selection with highlighting

Use `Plushie.Selection` to manage single or multi-select state. The
selection state is a standalone data structure. Toggle items and
check membership:

```elixir
# In init:
selection: Selection.new(mode: :multi)

# In view:
keyed_column spacing: 4 do
  for item <- model.items do
    container item.id,
      style: if(Selection.selected?(model.selection, item.id),
        do: :primary, else: :transparent) do
      row spacing: 8 do
        checkbox("select", Selection.selected?(model.selection, item.id))
        text(item.id <> "-name", item.name)
      end
    end
  end
end

# In update:
def update(model, %WidgetEvent{type: :toggle, id: "select", scope: [item_id | _]}) do
  %{model | selection: Selection.toggle(model.selection, item_id)}
end
```

### Undo with coalesced keystrokes

Use `Plushie.Undo` to track reversible changes. Coalescing groups
rapid sequential changes (like typing) into a single undo step:

```elixir
# In init:
undo: Undo.new("")

# In update: track editor changes with coalescing
def update(model, %WidgetEvent{type: :input, id: "editor", value: text}) do
  undo = Undo.apply(model.undo, %{
    apply: fn _ -> text end,
    undo: fn _ -> model.source end,
    coalesce: :typing,
    coalesce_window_ms: 500
  })
  %{model | source: text, undo: undo}
end

# Ctrl+Z / Ctrl+Shift+Z:
def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: false}}) do
  if Undo.can_undo?(model.undo) do
    undo = Undo.undo(model.undo)
    %{model | undo: undo, source: Undo.current(undo)}
  else
    model
  end
end

def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: true}}) do
  if Undo.can_redo?(model.undo) do
    undo = Undo.redo(model.undo)
    %{model | undo: undo, source: Undo.current(undo)}
  else
    model
  end
end
```

### Data query with sort controls

Use `Plushie.Data` to filter, sort, and paginate in-memory collections:

```elixir
defp query_items(model) do
  Data.query(model.items,
    search: if(model.query != "", do: {[:name, :email], model.query}),
    sort: {model.sort_dir, model.sort_field},
    page: model.page,
    page_size: 25
  )
end

# In view: sortable column headers
row spacing: 0 do
  for col <- [:name, :email, :role] do
    button("sort:#{col}", "#{col} #{sort_indicator(model, col)}",
      style: :text, width: {:fill_portion, 1})
  end
end

# In update: toggle sort direction
def update(model, %WidgetEvent{type: :click, id: "sort:" <> field}) do
  field = String.to_existing_atom(field)
  dir = if model.sort_field == field and model.sort_dir == :asc, do: :desc, else: :asc
  %{model | sort_field: field, sort_dir: dir}
end
```

## Interaction patterns

### Debounced search

Don't search on every keystroke. Wait for a pause. Use
`Command.send_after` which cancels-and-restarts on each keystroke.
The search only fires when the user stops typing:

```elixir
def update(model, %WidgetEvent{type: :input, id: "search", value: query}) do
  # Update the display immediately
  model = %{model | query: query}
  # Debounce: cancel previous timer, start new one
  {model, Command.send_after(300, {:run_search, query})}
end

def update(model, {:run_search, query}) do
  # Only runs if no keystroke arrived in the last 300ms
  %{model | results: search(model.items, query)}
end
```

`send_after` with the same event term cancels the previous timer
automatically. No manual cleanup needed.

### Context menu

Right-click menu using `pointer_area` and the window-level stack:

```elixir
# In the content area, wrap the target in a pointer_area:
pointer_area "item-#{item.id}",
  on_right_press: true do
  text(item.id, item.name)
end

def update(model, %WidgetEvent{type: :press, data: %{button: :right}, scope: [item_id | _]}) do
  %{model | context_menu: %{item_id: item_id, x: model.cursor_x, y: model.cursor_y}}
end

# In the window-level stack, render the menu at the cursor position:
if model.context_menu do
  pin x: model.context_menu.x, y: model.context_menu.y do
    container "ctx-menu", padding: 4, background: "#fff",
      border: Border.new() |> Border.width(1) |> Border.color("#ddd") |> Border.rounded(4),
      shadow: Shadow.new() |> Shadow.color("#0000001a") |> Shadow.blur_radius(8) do
      column spacing: 2 do
        button("ctx-edit", "Edit", style: :text, width: :fill)
        button("ctx-delete", "Delete", style: :text, width: :fill)
      end
    end
  end
end
```

Dismiss on any click outside the menu or on Escape.

### Keyboard shortcuts

Organise shortcuts with a dedicated function to keep `update/2` clean:

```elixir
def subscribe(_model) do
  [Plushie.Subscription.on_key_press(:keys)]
end

def update(model, %KeyEvent{type: :press} = key) do
  case shortcut(key) do
    :save -> save(model)
    :undo -> undo(model)
    :redo -> redo(model)
    :new -> {model, Command.focus("new-name")}
    :find -> {model, Command.focus("search")}
    :escape -> %{model | context_menu: nil, show_modal: false}
    nil -> model
  end
end

defp shortcut(%KeyEvent{key: "s", modifiers: %{command: true}}), do: :save
defp shortcut(%KeyEvent{key: "z", modifiers: %{command: true, shift: false}}), do: :undo
defp shortcut(%KeyEvent{key: "z", modifiers: %{command: true, shift: true}}), do: :redo
defp shortcut(%KeyEvent{key: "n", modifiers: %{command: true}}), do: :new
defp shortcut(%KeyEvent{key: "f", modifiers: %{command: true}}), do: :find
defp shortcut(%KeyEvent{key: :escape}), do: :escape
defp shortcut(_), do: nil
```

The `command` modifier is platform-aware: Ctrl on Linux/Windows, Cmd on
macOS.

### Focus management

Return focus to the right place after actions:

```elixir
# After deleting an item, focus the next item in the list:
def update(model, %WidgetEvent{type: :click, id: "delete", scope: [item_id | _]}) do
  index = Enum.find_index(model.items, &(&1.id == item_id))
  items = List.delete_at(model.items, index)
  next_id = Enum.at(items, min(index, length(items) - 1))

  focus_cmd = if next_id, do: Command.focus("#{next_id.id}/select"), else: Command.none()
  {%{model | items: items}, focus_cmd}
end

# After closing a modal, return focus to the element that opened it:
def update(model, %WidgetEvent{type: :click, id: "modal-confirm"}) do
  model = perform_action(model, model.pending_action)
  {%{model | show_modal: false}, Command.focus(model.modal_trigger_id)}
end
```

Good focus management prevents keyboard and screen reader users from
losing their place. Always think about where focus should go after
removing content, closing dialogs, or completing flows.

### Multi-window detail view

Open an item in its own window. The `view/1` conditionally adds a
second window when an item is detached:

```elixir
def view(model) do
  windows = [
    window "main", title: "Items" do
      keyed_column spacing: 4 do
        for item <- model.items do
          container item.id do
            row spacing: 8 do
              text(item.id <> "-name", item.name)
              button("detach", "Open in window")
            end
          end
        end
      end
    end
  ]

  for id <- model.detached_items, item = find_item(model, id), reduce: windows do
    acc ->
      acc ++ [
        window "detail:#{id}",
          title: item.name,
          exit_on_close_request: false do
          detail_view(item)
        end
      ]
  end
end

def update(model, %WidgetEvent{type: :click, id: "detach", scope: [item_id | _]}) do
  %{model | detached_items: [item_id | model.detached_items]}
end

def update(model, %WindowEvent{type: :close_requested, window_id: "detail:" <> item_id}) do
  %{model | detached_items: List.delete(model.detached_items, item_id)}
end
```

Each detached window has `exit_on_close_request: false` so closing it
only removes it from the view rather than exiting the app.

## See also

- [Built-in Widgets reference](built-in-widgets.md) - widget catalog
- [Canvas reference](canvas.md) - shapes, transforms, interactive groups
- [Styling reference](themes-and-styling.md) - Color, Theme, StyleMap, Border,
  Shadow, Gradient
- [Layout reference](windows-and-layout.md) - sizing, alignment, containers
- [Animation reference](animation.md) - transitions, springs, sequences
- [Custom Widgets reference](custom-widgets.md) - extracting reusable
  widgets from patterns
- `Plushie.Undo`, `Plushie.Data`, `Plushie.Selection`, `Plushie.Route`
  - state helper module docs
