Examples

# Example: inline edit + dirty tracking

Click-to-edit that replaces a Stimulus controller plus three routes: Enter saves, Escape cancels, focus survives the morph. Plus dirty-field tracking — an “Unsaved” badge and a leave-guard — with ZERO state shipped to the client.

## Try it — click to edit

Click the value below to edit it, then **Enter** (or Save) to persist and **Escape** (or Cancel) to back out. The mode (`editing`) rides in the signed token alongside the record's GlobalID, so the show ↔ edit flip is one reactive round trip — no client state, no bespoke JS.

Click me to rename inline

(click to edit)

```ruby
# frozen_string_literal: true

# Record-backed reactive component that ALSO carries transient mode as signed
# state (issue #6). `record` is re-found via GlobalID; `attribute` (which column)
# and `editing` (the show ↔ edit mode) are signed state that must survive every
# action round trip — the classic "click to edit, Enter to save, Escape to
# cancel" widget that would otherwise be a Stimulus controller plus three routes.
class InlineEditComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :record
  reactive_state :attribute, :editing

  action :edit
  action :cancel
  action :save, params: { value: :string }

  def initialize(record:, attribute: :title, editing: false)
    @record = record
    @attribute = attribute.to_sym
    @editing = editing
  end

  def id = dom_id(@record, "inline_#{@attribute}")

  def edit = @editing = true
  def cancel = @editing = false

  def save(value:)
    @record.update!(@attribute => value)
    @editing = false
  end

  def view_template
    span(**reactive_root(class: 'inline-flex items-center gap-2')) do
      if @editing
        # The input only holds the value — the form fields it collects travel with
        # whichever action fires. `on(:save)` lives on the Save button, not the
        # input, so focusing the field doesn't dispatch save and collapse edit mode.
        # Escape in the field cancels — event: "keydown.esc" is Stimulus's native
        # keyboard filter, so ONLY Escape fires it (Enter is free to save elsewhere).
        input(**mix(on(:cancel, event: 'keydown.esc'),
                    name: 'value', value: current_value,
                    class: 'input input-bordered input-sm', data: { testid: 'field' }))
        button(**mix(on(:save), class: 'btn btn-sm btn-primary', data: { testid: 'save' })) { 'Save' }
        button(**mix(on(:cancel), class: 'btn btn-sm btn-ghost', data: { testid: 'cancel' })) { 'Cancel' }
      else
        span(**mix(on(:edit),
                   class: 'cursor-pointer border-b border-dashed border-base-content/40',
                   data: { testid: 'display' })) { current_value.presence || '—' }
        span(class: 'text-xs opacity-50') { '(click to edit)' }
      end
    end
  end

  private

  def current_value = @record.public_send(@attribute).to_s
end
```

## How it works

`reactive_record :record` signs the record's GlobalID; `reactive_state :attribute, :editing` signs which column and the mode. Every action re-finds the record server-side and re-renders. `save` lives on the **Save button**, not the input — so focusing the field doesn't dispatch save and collapse edit mode. `Escape` is bound with `event: "click keydown.esc"`, Stimulus's native keyboard filter.

## Try it — dirty tracking

Edit the field: an **“Unsaved”** badge appears on the first keystroke and the browser warns before you navigate away. Save and it clears — all with **no state shipped to the client**. The browser already holds the last server-rendered value in `input.defaultValue`, so *dirty = current ≠ default*; the badge is revealed purely by CSS while the root carries `data-reactive-dirty`.

Unsaved

Save

Edit me to see the Unsaved badge

```ruby
# frozen_string_literal: true

# Dirty-field tracking (issue #103) with ZERO shipped state. The browser already
# holds the last server-rendered value: input.defaultValue IS the attribute from
# the last render, so dirty = current ≠ default — no state travels to the client.
#
#   * reactive_root(track_dirty: true, warn_unsaved: true) — every input on this
#     root re-scans on change (track_dirty mixes input->reactive#trackDirty onto
#     the root's data-action), and warn_unsaved arms a navigate-away guard gated
#     on the LIVE dirty count.
#   * reactive_field(:title, value:, dirty: true) — the field carries the
#     trackDirty descriptor (redundant with the root here, but shows the per-field
#     opt-in).
#   * The "Unsaved" badge is revealed purely by CSS ([data-reactive-dirty]) — no
#     Ruby, no per-field JS. It appears on the first keystroke and clears when the
#     morph reply writes a fresh defaultValue equal to the input's value.
#
# save morphs so the reply re-renders the field with the NEW value as its fresh
# default; the post-morph re-scan then finds it clean and the badge clears with no
# full-page reload.
class DirtyFormComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :todo
  action :save, params: { title: :string }

  def initialize(todo:)
    @todo = todo
  end

  def id = dom_id(@todo, 'dirtyform')

  def save(title:)
    @todo.update!(title:) if title.present?
    reply.morph
  end

  def view_template
    # The badge is hidden until the root carries data-reactive-dirty (set live by
    # trackDirty when current ≠ default), then revealed — pure CSS, no Ruby.
    style do
      # Static CSS, no user input — Phlex safe(), not Rails html_safe.
      css = '[data-testid="dirty-badge"]{display:none} ' \
            '[data-reactive-dirty] [data-testid="dirty-badge"]{display:inline-flex}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
    div(**reactive_root(track_dirty: true, warn_unsaved: true, class: 'flex items-center gap-2')) do
      # The field's baseline is its own defaultValue (= @todo.title from this
      # render). dirty: true also wires trackDirty onto the field itself.
      input(**mix(reactive_field(:title, value: @todo.title, dirty: true),
                  class: 'input input-bordered input-sm', data: { testid: 'title' }))
      # Revealed purely by CSS while the root is dirty — no Ruby toggles it.
      span(class: 'badge badge-warning badge-sm', data: { testid: 'dirty-badge' }) { 'Unsaved' }
      button(**mix(on(:save), class: 'btn btn-sm btn-primary', data: { testid: 'save' })) { 'Save' }
      span(class: 'text-sm opacity-60', data: { testid: 'current' }) { @todo.title }
    end
  end
end
```

## How dirty tracking works

`reactive_root(track_dirty: true, warn_unsaved: true)` mixes `input->reactive#trackDirty` onto the root and arms a navigate-away guard gated on the live dirty count. `reactive_field(:title, dirty: true)` opts the field in too. On `save` the action morphs, so the reply re-renders the field with the NEW value as its fresh `defaultValue` — the post-morph re-scan finds it clean and the badge clears without a full-page reload.

## Notes

> **Tip:** Both widgets bind to their own demo record, so editing them here won't touch the [live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list). In your app the same `InlineEditComponent` works against any record + attribute — `render InlineEditComponent.new(record: @user, attribute: :name)`.