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.
# 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
endreactive_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.
| 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\nnormalised, one trailing newline dropped. A single-line field folds newlines to spaces. - The morph. While the field is focused, a
reply.morphor 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-valueon the element lets the morph write it even while focused, as for inputs. The client copies a held field's attributes itself, so Turbo'sturbo:before-morph-attributeandturbo:morph-elementdo 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 tocontenteditable="true", whenever it reaches the page (at load, by a stream, a frame or a morph), because such an engine cannot editplaintext-onlyat all. Line breaks pasted into a single-line field become spaces. - Enter is left alone during IME composition.
- Dirty and drafts.
reactive_dirtymarks the field when its text differs from the text of its last server render;reactive_persistdrafts 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.