Guide

# Editable text

Turn any element into a plain-text field with `reactive_editable`: an inline title, a card label, a note. Typed text and the caret survive the reply morph, paste is plain text, Enter and Escape do what a field should.

## Try it

Click the title and type: every pause saves (a debounced `input` action) and the reply morphs the card, yet the caret stays where it is and nothing you typed during the request is lost. **Enter** in the title moves to the note; the note keeps its line breaks. **Escape** reverts a field to its last saved text.

### Click to rename this card

Saving…

Line breaks survive.
Press Escape to undo an edit.

Saved title: Click to rename this card

The note is demo state: it lives in the card, not in the database.

```ruby
# frozen_string_literal: true

# Editable text (issue #351): a card whose title and note are edited where they
# are shown. reactive_editable renders the attributes onto your own element —
# contenteditable="plaintext-only", the param name, role="textbox" and the
# aria/placeholder hooks — and the client treats it like an input:
#
#   * its text travels as the param (line breaks as "\n" in the multiline note);
#   * a reply morph keeps the focused field's text and caret, so the debounced
#     autosave never eats what you type while it is in flight;
#   * paste is plain text; Enter in the single-line title moves to the note
#     (on_enter:), Escape reverts to the last saved text;
#   * reactive_dirty diffs it against that saved text, like an input.
class EditableCardComponent < Phlex::HTML
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :todo
  reactive_state :note
  reactive_dirty
  action :rename, params: { title: :string }
  action :write, params: { note: :string }

  def initialize(todo:, note: "Line breaks survive.\nPress Escape to undo an edit.")
    @todo = todo
    @note = note
  end

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

  def rename(title:)
    # A blank title is never saved; the card says so (the focused field keeps
    # what was typed, so Escape is the way back to the saved title).
    title = title.strip
    if title.empty?
      @blank_title = true
    else
      @todo.update!(title:)
    end
    reply.morph
  end

  def write(note:)
    @note = note
    reply.morph
  end

  def view_template
    # The placeholder is drawn by CSS from data-reactive-placeholder; the badge
    # shows while the root is dirty. Static CSS — Phlex safe(), not html_safe.
    style do
      css = '[data-reactive-placeholder]:empty::before{content:attr(data-reactive-placeholder);opacity:.4} ' \
            '[data-reactive-editable=multi]{white-space:pre-wrap} ' \
            '[data-testid="editable-badge"]{display:none} ' \
            '[data-reactive-dirty] [data-testid="editable-badge"]{display:inline-flex}'
      raw(safe(css)) # rubocop:disable Rails/OutputSafety
    end
    div(**reactive_root(class: 'card bg-base-200 max-w-md')) do
      div(class: 'card-body gap-3') do
        div(class: 'flex items-center gap-2') do
          h3(**mix(reactive_editable(:title, placeholder: 'Untitled', on_enter: js.focus('#editable-note')),
                   on(:rename, event: 'input', debounce: 400),
                   class: 'card-title outline-none focus:underline', data: { testid: 'editable-title' })) do
            @todo.title
          end
          span(class: 'badge badge-warning badge-sm', data: { testid: 'editable-badge' }) { 'Saving…' }
        end
        p(**mix(reactive_editable(:note, multiline: true, placeholder: 'Add a note'),
                on(:write, event: 'input', debounce: 400),
                id: 'editable-note', class: 'outline-none text-sm', data: { testid: 'editable-note' })) do
          @note
        end
        p(class: 'text-xs opacity-60') { "Saved title: #{@todo.title}" }
        p(class: 'text-xs opacity-60') { 'The note is demo state: it lives in the card, not in the database.' }
        if @blank_title
          p(class: 'text-xs text-warning', data: { testid: 'editable-blank' }) do
            'A title can’t be blank — not saved. Press Escape to restore it.'
          end
        end
      end
    end
  end
end
```

## reactive_editable

The element is yours; the helper renders its attributes. Bind an action with `on()` through `mix`, as for any field:

```ruby
h3(**reactive_editable(:title, placeholder: "Untitled")) { @card.title }
p(**reactive_editable(:summary, multiline: true)) { @card.summary }

# Autosave while typing:
h3(**mix(reactive_editable(:title), on(:rename, event: "input", debounce: 300))) { @card.title }
```

It renders `name` (scope-aware, like `reactive_field`), `contenteditable="plaintext-only"`, `role="textbox"`, `aria-multiline`, `aria-placeholder` and `data-reactive-placeholder`, and the `data-reactive-editable*` markers the client reads.

| Option | Default | Effect |
| --- | --- | --- |
| `multiline:` | `false` | `true` keeps line breaks; single-line folds them to spaces and swallows Enter |
| `placeholder:` | `nil` | `aria-placeholder` + `data-reactive-placeholder` for CSS |
| `on_enter:` | `nil` | a `js` chain run on Enter (root-scoped, as `on_client`), or `:submit` to request-submit the field's form. In a multiline field Shift+Enter still breaks the line |
| `on_escape:` | `:revert` | restore the last server-rendered text and blur; `nil` turns it off |
| `spellcheck:` | `true` | `false` renders `spellcheck="false"` |

It works in a `Phlex::Reactive::ClientBindings` view too (no token): there the field's text is drafted by `reactive_persist` and read by `reactive_show`.

## What the client does

- **The value.** The text with line breaks as `\n` (a `<br>` or a block element is one), `\r\n` normalised, one trailing newline dropped. A single-line field folds newlines to spaces.
- **The morph.** While the field is focused, a `reply.morph` or a broadcast morph leaves its text and caret alone; its attributes (a validation class, `aria-invalid`) still update. Unfocused, the server's text wins. `data-reactive-morph-value` on the element lets the morph write it even while focused, as for inputs. The client copies a held field's attributes itself, so Turbo's `turbo:before-morph-attribute` and `turbo:morph-element` do not fire for that element while it is focused.
- **Paste and drop** insert plain text: natively where the engine has `plaintext-only` (all current ones), through a handler where it does not. There the client switches every editable to `contenteditable="true"`, whenever it reaches the page (at load, by a stream, a frame or a morph), because such an engine cannot edit `plaintext-only` at all. Line breaks pasted into a single-line field become spaces.
- **Enter** is left alone during IME composition.
- **Dirty and drafts.** `reactive_dirty` marks the field when its text differs from the text of its last server render; `reactive_persist` drafts and restores it with its line breaks.

A bare named `[contenteditable]` without the helper is still collected as before (its `textContent`) and is not held through a morph.

## Styling

The placeholder and multi-line display are CSS's:

```css
[data-reactive-placeholder]:empty::before {
  content: attr(data-reactive-placeholder);
  opacity: .4;
}
[data-reactive-editable="multi"] { white-space: pre-wrap; }
```

The client removes the `<br>` browsers leave in a field emptied by the user, so `:empty` matches again.

## Notes

> **Note:** The value travels with reactive actions. A native (non-reactive) form submit does not post a contenteditable, so bind the save with `on()` or `on_enter: :submit` on a form whose submit a reactive action intercepts. For rich text (bold, links) use an editor such as Lexxy or Trix, which the collector reads by its `.value`.An editable can arrive later: a stream can append it into a root that is already on the page. Its keys and paste work from its first focus. On the split client a stream that brings one starts loading the bindings module before it renders.