Editable

Inline editing component - click to edit text, blur or Enter to save, Escape to cancel.

Key Features

  • Click to Edit - Click on any text to reveal input field
  • Auto-save - Automatically saves on blur or Enter key
  • Cancel Support - Press Escape to cancel edits
  • 3 Input Types - text, textarea, number
  • Server Validation - Full support for Rails validations via Turbo
  • Semantic Tags - Use as: :h1, :h2, :p, etc. with automatic typography
  • Placeholder - Show helpful text when value is empty
  • Validation - Required, maxlength, minlength, min, max
  • Full Dark Mode - Beautiful in light and dark themes
  • Turbo Compatible - Works seamlessly with Turbo Streams

Basic Usage

Click on the text below to edit it. Changes are sent to the server via PATCH request.

This is a real Post from the database. Try editing the title:

View this post →
Saved from post editable from doc live!

Changes are instantly saved to the database. Visit the post to confirm!

Basic Usage
<%= rui_editable @post, :title, url: post_path(@post) %>

<%= rui_editable @post, :subtitle,
      url: post_path(@post),
      placeholder: "Add a subtitle..." %>

Input Types

Three input types for different content formats.

Text Input (Default)

For single-line text like titles, names, etc.

Text Input
<%= rui_editable @post, :title,
      url: post_path(@post),
      input_type: :text %>

Textarea

For multi-line content like descriptions, notes, body text.

Textarea
<%= rui_editable @post, :body,
      url: post_path(@post),
      input_type: :textarea,
      rows: 4 %>

Number Input

For numeric values like prices, quantities, ratings.

Number Input
<%= rui_editable @product, :price,
      url: product_path(@product),
      input_type: :number,
      min: 0,
      max: 10000 %>

Semantic Tags

Use as: to render as any semantic HTML element. Headings (:h1 through :h6) automatically receive appropriate typography styles - no manual classes needed.

Semantic Tags with Auto-Typography
<%= rui_editable @post, :title, url: post_path(@post), as: :h1 %>

<%= rui_editable @post, :subtitle, url: post_path(@post), as: :h2 %>

<%= rui_editable @post, :excerpt, url: post_path(@post), as: :p %>

<%= rui_editable @post, :content,
      url: post_path(@post),
      as: :div,
      input_type: :textarea %>

Tip: Unlike adding manual classes, as: :h1 applies the same responsive typography as rui_text as: :h1 - including responsive text sizes and proper font weights.

Validation

Client-side validation provides instant feedback before sending to server.

Validation Options
<%= rui_editable @user, :name,
      url: user_path(@user),
      required: true %>

<%= rui_editable @post, :title,
      url: post_path(@post),
      minlength: 3,
      maxlength: 100 %>

<%= rui_editable @product, :quantity,
      url: product_path(@product),
      input_type: :number,
      min: 0,
      max: 999 %>

Controller Setup

Your Rails controller needs to handle PATCH requests for inline updates.

Controller
class PostsController < ApplicationController
  def update
    @post = Post.find(params[:id])

    if @post.update(post_params)
      respond_to do |format|
        format.turbo_stream { head :ok }
        format.html { redirect_to @post }
        format.json { render json: { success: true } }
      end
    else
      respond_to do |format|
        format.turbo_stream {
          render turbo_stream: turbo_stream.replace(
            "editable_post_#{@post.id}_title",
            partial: "posts/title_error",
            locals: { post: @post }
          )
        }
        format.html { render :edit }
        format.json {
          render json: { errors: @post.errors.full_messages },
          status: :unprocessable_entity
        }
      end
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :body, :subtitle)
  end
end

Turbo Integration

The editable component works seamlessly with Turbo Streams for real-time updates.

Turbo Stream Response
# In your controller, return Turbo Stream for error handling:
respond_to do |format|
  format.turbo_stream do
    if @post.errors.any?
      render turbo_stream: turbo_stream.replace(
        dom_id(@post, :editable_title),
        partial: "posts/editable_title",
        locals: { post: @post, error: @post.errors[:title].first }
      )
    else
      head :ok
    end
  end
end

The component automatically handles Turbo Stream responses. On success, it updates the display text. On error, it shows the error message and keeps the input focused for correction.

Real-World Examples

Editable Card Title

Card with Editable Title
<div class="bg-white dark:bg-zinc-900 rounded-lg shadow p-6">
  <%= rui_editable @task, :name,
        url: task_path(@task),
        as: :h2,
        placeholder: "Untitled Task" %>

  <%= rui_editable @task, :description,
        url: task_path(@task),
        as: :p,
        input_type: :textarea,
        rows: 3,
        placeholder: "Add a description..." %>
</div>

Editable Table Cell

Table with Editable Cells
<%= rui_table @products do |table| %>
  <% table.with_column("Name") do |product| %>
    <%= rui_editable product, :name, url: product_path(product) %>
  <% end %>

  <% table.with_column("Price") do |product| %>
    <%= rui_editable product, :price,
          url: product_path(product),
          input_type: :number,
          min: 0 %>
  <% end %>

  <% table.with_column("Stock") do |product| %>
    <%= rui_editable product, :quantity,
          url: product_path(product),
          input_type: :number,
          min: 0 %>
  <% end %>
<% end %>

User Profile Settings

Profile Settings
<div class="space-y-4">
  <div class="flex items-center justify-between">
    <span class="text-sm font-medium text-zinc-500">Display Name</span>
    <%= rui_editable @user, :display_name,
          url: user_path(@user),
          required: true,
          maxlength: 50 %>
  </div>

  <div class="flex items-center justify-between">
    <span class="text-sm font-medium text-zinc-500">Bio</span>
    <%= rui_editable @user, :bio,
          url: user_path(@user),
          input_type: :textarea,
          rows: 3,
          maxlength: 500,
          placeholder: "Tell us about yourself..." %>
  </div>
</div>

All Options Reference

Complete reference showing every available option with copy-paste ready examples.

Minimal Required Usage

The bare minimum to create an editable field.

Minimum Required
<%= rui_editable @post, :title, url: post_path(@post) %>

All Options Example

Every available option with annotations.

All Available Options
<%= rui_editable @post, :title,
      # Required
      url: post_path(@post),               # PATCH endpoint URL

      # Semantic element (auto-styled for headings)
      as: :h1,                             # Element type (:span, :h1-h6, :p, :div)
      id: "post-title-editor",             # Custom HTML ID (optional)

      # Input configuration
      input_type: :text,                   # :text (default), :textarea, :number
      placeholder: "Enter a title...",     # Shown when value is empty

      # Validation (client-side)
      required: true,                      # Field cannot be empty
      minlength: 5,                        # Minimum characters
      maxlength: 100,                      # Maximum characters

      # Number-specific options (when input_type: :number)
      min: nil,                            # Minimum value
      max: nil,                            # Maximum value

      # Textarea-specific options (when input_type: :textarea)
      rows: 3                              # Number of visible rows
%>

Input Type: Text (Default)

Text Input - Complete Example
<%= rui_editable @user, :name,
      url: user_path(@user),
      placeholder: "Enter your name",
      required: true,
      minlength: 2,
      maxlength: 50 %>

Input Type: Textarea

Textarea - Complete Example
<%= rui_editable @post, :body,
      url: post_path(@post),
      as: :div,
      input_type: :textarea,
      rows: 6,
      placeholder: "Write your content here...",
      minlength: 10,
      maxlength: 5000 %>

Input Type: Number

Number Input - Complete Example
<%= rui_editable @product, :price,
      url: product_path(@product),
      input_type: :number,
      placeholder: "0.00",
      required: true,
      min: 0,
      max: 99999 %>

<%= rui_editable @cart_item, :quantity,
      url: cart_item_path(@cart_item),
      input_type: :number,
      min: 1,
      max: 100 %>

Custom ID for Targeting

Custom ID Example
<%= rui_editable @post, :title,
      url: post_path(@post),
      id: "editable-post-<%%=@post.id%>-title",
      as: :h1 %>

<script>
  // Find the editable element
  const editable = document.getElementById('editable-post-123-title')

  // Listen for save events
  editable.addEventListener('editable:saved', (e) => {
    console.log('Saved:', e.detail.value)
  })
</script>

Complete Setup Guide

Everything you need to implement editable fields from scratch.

1. Model

app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user

  validates :title, presence: true, length: { minimum: 3, maximum: 100 }
  validates :body, length: { maximum: 10000 }
  validates :priority, numericality: { only_integer: true, in: 1..5 }, allow_nil: true
end

2. Controller

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action :set_post, only: [:show, :update]

  def show
  end

  def update
    if @post.update(post_params)
      respond_to do |format|
        # Turbo Stream - return success for inline update
        format.turbo_stream { head :ok }

        # JSON - for API clients
        format.json { render json: { success: true, value: @post.send(post_params.keys.first) } }

        # HTML fallback
        format.html { redirect_to @post, notice: 'Updated successfully.' }
      end
    else
      respond_to do |format|
        # Turbo Stream - replace with error state
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            dom_id(@post, :editable),
            partial: 'posts/editable_error',
            locals: { post: @post }
          ), status: :unprocessable_entity
        end

        # JSON - return errors
        format.json do
          render json: { success: false, errors: @post.errors.full_messages },
                 status: :unprocessable_entity
        end

        # HTML fallback
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :body, :priority, :subtitle)
  end
end

3. View

app/views/posts/show.html.erb
<article class="max-w-4xl mx-auto p-6">
  <%= rui_editable @post, :title,
        url: post_path(@post),
        as: :h1,
        placeholder: "Untitled Post",
        required: true,
        minlength: 3,
        maxlength: 100 %>

  <%= rui_editable @post, :subtitle,
        url: post_path(@post),
        as: :h2,
        placeholder: "Add a subtitle...",
        maxlength: 200 %>

  <%= rui_editable @post, :body,
        url: post_path(@post),
        as: :div,
        input_type: :textarea,
        rows: 10,
        placeholder: "Start writing...",
        minlength: 10 %>

  <div class="mt-8 flex items-center gap-4">
    <span class="text-sm text-zinc-500">Priority:</span>
    <%= rui_editable @post, :priority,
          url: post_path(@post),
          input_type: :number,
          min: 1,
          max: 5,
          placeholder: "1-5" %>
  </div>
</article>

4. Routes

config/routes.rb
Rails.application.routes.draw do
  resources :posts, only: [:show, :update]
end

Visual Indicators

The component provides clear visual cues to indicate editability and state changes.

Dashed Underline

A subtle dashed border appears below editable text, signaling that the content can be modified. Always visible to provide consistent affordance.

Pencil Icon

A small pencil icon appears next to editable text. At 40% opacity by default, it brightens to 70% on hover, reinforcing the edit action. Hidden during editing mode.

Hover Background

A subtle background color appears on hover, providing clear visual feedback that the element is interactive and ready to be clicked.

Success Flash

After a successful save, a green background briefly flashes and fades out over 1.5 seconds. This provides immediate visual confirmation that changes were saved.

Error State

When validation fails or a network error occurs, the input displays a red ring and an error message appears below. The input stays focused for easy correction.

Saving Indicator

During save, the component shows "Saving..." text and reduces opacity, preventing interaction until the request completes.

Accessibility

The Editable component follows accessibility best practices for inline editing interfaces.

Focus Management

  • Input receives focus automatically when editing starts
  • Focus is restored to display element after save/cancel
  • Clear focus ring indicators for keyboard users

Keyboard Navigation

  • Enter saves changes (for text inputs)
  • Cmd/Ctrl + Enter saves changes (for textareas)
  • Escape cancels editing and reverts to original value
  • Blur (clicking outside) triggers auto-save

Visual Feedback

  • Hover state indicates clickable/editable area
  • Saving indicator shows when request is in progress
  • Error messages are displayed inline with the input

JavaScript API

The Editable component exposes Stimulus actions and custom events for programmatic control.

Stimulus Values

Configure the editable behavior via data-*-value attributes:

Value Type Description
url String PATCH endpoint URL
field String Form field name (e.g., "post[title]")
current String Current value
inputType String Input type (text, textarea, number)
placeholder String Placeholder text

Stimulus Actions

Use these actions via data-action attributes:

Action Description
editable#edit Switch to edit mode, show input field
editable#save Save changes to server via PATCH request
editable#cancel Cancel edits, revert to original value
Using Stimulus Actions
<div data-controller="editable"
     data-editable-url-value="<%= post_path(@post) %>"
     data-editable-field-value="post[title]"
     data-editable-current-value="<%= @post.title %>">

  <span data-editable-target="display"><%= @post.title %></span>

  <button data-action="editable#edit" class="text-sm text-blue-600">
    Edit
  </button>
</div>

Custom Events

Listen to these events for lifecycle hooks:

Event When Detail
editable:saved Value saved successfully { value, field }
editable:cancelled Edit cancelled by user { originalValue }
editable:error Save failed with error { error, field }
Listening to Events
<div data-controller="my-controller"
     data-action="editable:saved->my-controller#onSave
                  editable:cancelled->my-controller#onCancel
                  editable:error->my-controller#onError">
  <%= rui_editable @post, :title, url: post_path(@post) %>
</div>

// my_controller.js
onSave(event) {
  const { value, field } = event.detail
  console.log(`Saved ${field}: ${value}`)
}

onCancel(event) {
  const { originalValue } = event.detail
  console.log(`Cancelled, reverted to: ${originalValue}`)
}

Targets

Reference these targets in custom JavaScript:

Target Description
display The text display element (click to edit)
input The input/textarea element
saving Saving indicator element
error Error message element
icon Pencil icon element (hidden during editing)

Programmatic Control

Control the editable from JavaScript using the Stimulus controller:

Programmatic Control
// Get the editable controller
const editableElement = document.querySelector('[data-controller="editable"]')
const editableController = application.getControllerForElementAndIdentifier(
  editableElement,
  "editable"
)

// Switch to edit mode programmatically
editableController.edit()

// Save current value
editableController.save()

// Cancel and revert
editableController.cancel()

API Reference

rui_editable(model, attribute, **options)

Inline editable text component

Parameter Type Default Description
model* ActiveRecord::Base The model object to edit
attribute* Symbol The attribute name to edit
url String PATCH endpoint URL (required for saving)
as Symbol :span Semantic element with auto-typography (:span, :h1-h6, :p, :div)
placeholder String "Click to edit" Text shown when value is empty
input_type Symbol :text Input type (:text, :textarea, :number)
required Boolean false Whether the field is required
maxlength Integer Maximum character length
minlength Integer Minimum character length
min Integer Minimum value (for number type)
max Integer Maximum value (for number type)
rows Integer 3 Number of rows (for textarea type)
id String Custom ID (auto-generated if nil)
class String Custom CSS classes