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.

app/components/editable_card_component.rb
# 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:

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.

OptionDefaultEffect
multiline:falsetrue keeps line breaks; single-line folds them to spaces and swallows Enter
placeholder:nilaria-placeholder + data-reactive-placeholder for CSS
on_enter:nila 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::revertrestore the last server-rendered text and blur; nil turns it off
spellcheck:truefalse 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:

[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#

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.