Editor Example

A dummy-only host Stimulus controller coordinates shadcn-rails components without adding a new registry component.

Live demo

Toggle marks or alignment, type in the textarea, then preview live or save to POST the form back to this page.

Host editor controller

The wrapper uses data-controller="editor" while shadcn ToggleGroup events drive the editor state.

Marks

Alignment

Preview

Params


    

Example source

app/views/docs/examples/_editor_demo.html.erb
<%= form_with scope: :editor, url: docs_editor_example_path, method: :post, data: { controller: "editor" }, class: "space-y-6" do %>
  <div class="space-y-4">
    <div class="flex flex-wrap items-center gap-3">
      <div class="space-y-2">
        <p class="text-sm font-medium">Marks</p>
        <%= render Shadcn::ToggleGroupComponent.new(
          type: :multiple,
          variant: :outline,
          name: "editor[marks]",
          value: @editor_params[:marks].to_s.split(","),
          data: { action: "shadcn--toggle-group:change->editor#marksChanged" }
        ) do |group| %>
          <% group.with_item(value: "bold", aria_label: "Bold") { "B" } %>
          <% group.with_item(value: "italic", aria_label: "Italic") { "I" } %>
          <% group.with_item(value: "underline", aria_label: "Underline") { "U" } %>
        <% end %>
      </div>

      <div class="space-y-2">
        <p class="text-sm font-medium">Alignment</p>
        <%= render Shadcn::ToggleGroupComponent.new(
          type: :single,
          variant: :outline,
          name: "editor[alignment]",
          value: @editor_params[:alignment],
          data: { action: "shadcn--toggle-group:change->editor#alignmentChanged" }
        ) do |group| %>
          <% group.with_item(value: "left", aria_label: "Align left") { "Left" } %>
          <% group.with_item(value: "center", aria_label: "Align center") { "Center" } %>
          <% group.with_item(value: "right", aria_label: "Align right") { "Right" } %>
        <% end %>
      </div>
    </div>

    <%= render Shadcn::TextareaComponent.new(
      name: "editor[body]",
      id: "editor_body",
      rows: 6,
      value: @editor_params[:body],
      data: {
        "editor-target": "canvas",
        action: "input->editor#canvasChanged"
      }
    ) %>

    <%= render Shadcn::ButtonGroupComponent.new do |group| %>
      <% group.with_button(variant: :outline, type: "button", data: { action: "click->editor#preview" }) { "Preview" } %>
      <% group.with_button(type: "submit") { "Save" } %>
    <% end %>
  </div>

  <div class="grid gap-4 md:grid-cols-2">
    <div class="rounded-lg border bg-card p-4">
      <h3 class="mb-2 text-sm font-medium">Preview</h3>
      <div data-editor-target="preview" class="min-h-24 rounded-md bg-muted p-3 text-sm"></div>
    </div>

    <div class="rounded-lg border bg-card p-4">
      <h3 class="mb-2 text-sm font-medium">Params</h3>
      <pre data-editor-target="params" class="min-h-24 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs"></pre>
    </div>
  </div>
<% end %>

Host Stimulus controller

app/javascript/controllers/editor_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["canvas", "params", "preview"]

  connect() {
    this.applyCurrentState()
    this.updatePreview()
    this.updateParams()
  }

  marksChanged(event) {
    this.applyMarks(this.normalizeValues(event.detail.value))
    this.updatePreview()
    this.updateParams()
  }

  alignmentChanged(event) {
    this.applyAlignment(this.normalizeValues(event.detail.value)[0] || "left")
    this.updatePreview()
    this.updateParams()
  }

  canvasChanged() {
    this.updatePreview()
    this.updateParams()
  }

  preview(event) {
    event.preventDefault()
    this.applyCurrentState()
    this.updatePreview()
  }

  applyCurrentState() {
    this.applyMarks(this.paramsForForm().editor.marks)
    this.applyAlignment(this.paramsForForm().editor.alignment)
  }

  applyMarks(marks) {
    this.canvasTarget.style.fontWeight = marks.includes("bold") ? "700" : "400"
    this.canvasTarget.style.fontStyle = marks.includes("italic") ? "italic" : "normal"
    this.canvasTarget.style.textDecoration = marks.includes("underline") ? "underline" : "none"
  }

  applyAlignment(alignment) {
    this.canvasTarget.style.textAlign = alignment
  }

  updatePreview() {
    this.previewTarget.textContent = this.canvasTarget.value || "Nothing to preview yet."
    this.previewTarget.style.fontWeight = this.canvasTarget.style.fontWeight
    this.previewTarget.style.fontStyle = this.canvasTarget.style.fontStyle
    this.previewTarget.style.textDecoration = this.canvasTarget.style.textDecoration
    this.previewTarget.style.textAlign = this.canvasTarget.style.textAlign
  }

  updateParams() {
    this.paramsTarget.textContent = JSON.stringify(this.paramsForForm(), null, 2)
  }

  paramsForForm() {
    const formData = new FormData(this.element)

    return {
      editor: {
        marks: this.normalizeValues(formData.get("editor[marks]")),
        alignment: formData.get("editor[alignment]") || "left",
        body: formData.get("editor[body]") || ""
      }
    }
  }

  normalizeValues(value) {
    if (Array.isArray(value)) return value
    if (!value) return []
    return value.toString().split(",").filter(Boolean)
  }
}