From 5cfd5f03f7e5ec86c1f9f00732a867dc5d02fbba Mon Sep 17 00:00:00 2001 From: Pavel Kalashnikov Date: Mon, 25 May 2026 08:03:22 +0400 Subject: [PATCH] Add rich_text_area --- README.md | 2 + app/assets/javascripts/tramway/tramway.js | 107 ++++++++++++++++++ app/components/tramway/form/builder.rb | 42 +++++++ .../tramway/entities_controller.rb | 4 + app/views/tramway/entities/edit.html.haml | 2 +- app/views/tramway/entities/new.html.haml | 2 +- config/tailwind.config.js | 9 ++ lib/tramway/base_form.rb | 6 + lib/tramway/utils/field.rb | 2 +- spec/components/tramway/form/builder_spec.rb | 58 ++++++++++ spec/features/entities/update_spec.rb | 2 +- spec/forms/base_form_spec.rb | 14 +++ 12 files changed, 246 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9142de4d..b4f6a6df 100644 --- a/README.md +++ b/README.md @@ -1203,6 +1203,7 @@ Checkboxes render dark while unchecked and use the light primary checked state. <%= f.select :role, [:admin, :user] %> <%= f.date_field :birth_date %> <%= f.datetime_field :confirmed_at %> + <%= f.rich_text_area :bio %> <%= f.tramway_select :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %> <%= f.file_field :file %> <%= f.submit 'Create User' %> @@ -1243,6 +1244,7 @@ Available form helpers: * date_field * datetime_field * time_field +* rich_text_area (Action Text/Trix editor with Tramway dark form classes) * tramway_select ([Stimulus-based](https://github.com/Purple-Magic/tramway#stimulus-based-inputs)) * submit diff --git a/app/assets/javascripts/tramway/tramway.js b/app/assets/javascripts/tramway/tramway.js index 3b767f1e..bec52b6c 100644 --- a/app/assets/javascripts/tramway/tramway.js +++ b/app/assets/javascripts/tramway/tramway.js @@ -1,5 +1,112 @@ import { Controller } from "@hotwired/stimulus" +const richTextToolbarClasses = { + toolbar: [ + "mb-2", + "rounded-md", + "border", + "!border-zinc-800", + "!bg-zinc-950", + "p-1" + ], + buttonGroup: [ + "!border-zinc-800", + "!bg-zinc-950" + ], + button: [ + "!border-zinc-800", + "!bg-zinc-950", + "!text-zinc-50", + "hover:!bg-zinc-900" + ], + dialog: [ + "rounded-md", + "border", + "!border-zinc-800", + "!bg-zinc-950", + "!text-zinc-50", + "shadow-sm" + ], + input: [ + "rounded-md", + "border", + "!border-zinc-800", + "!bg-zinc-950", + "!text-zinc-50", + "placeholder:text-zinc-500", + "focus:outline-none", + "focus:ring-2", + "focus:ring-zinc-300", + "focus:ring-offset-2", + "focus:ring-offset-zinc-950" + ] +} + +function installRichTextToolbarStyles() { + if (document.getElementById("tramway-rich-text-toolbar-styles")) return + + const style = document.createElement("style") + style.id = "tramway-rich-text-toolbar-styles" + style.textContent = ` + trix-toolbar[data-tramway-rich-text-styled="true"] .trix-button--icon::before { + filter: invert(1) !important; + opacity: 0.95 !important; + } + + trix-toolbar[data-tramway-rich-text-styled="true"] .trix-button--icon:disabled::before { + opacity: 0.35 !important; + } + ` + document.head.appendChild(style) +} + +function applyClasses(element, classes) { + if (element) element.classList.add(...classes) +} + +function syncRichTextButtonState(button) { + button.classList.remove("bg-zinc-50", "text-zinc-950", "!bg-zinc-50", "!text-zinc-950") + button.classList.add("bg-zinc-950", "text-zinc-50", "!bg-zinc-950", "!text-zinc-50") +} + +function styleRichTextToolbar(editor) { + const toolbar = document.getElementById(editor.getAttribute("toolbar")) + if (!toolbar || toolbar.dataset.tramwayRichTextStyled === "true") return + + installRichTextToolbarStyles() + toolbar.dataset.tramwayRichTextStyled = "true" + applyClasses(toolbar, richTextToolbarClasses.toolbar) + + toolbar.querySelectorAll(".trix-button-group").forEach((buttonGroup) => { + applyClasses(buttonGroup, richTextToolbarClasses.buttonGroup) + }) + + toolbar.querySelectorAll(".trix-button").forEach((button) => { + applyClasses(button, richTextToolbarClasses.button) + syncRichTextButtonState(button) + + new window.MutationObserver(() => syncRichTextButtonState(button)).observe(button, { + attributeFilter: ["class"], + attributes: true + }) + }) + + toolbar.querySelectorAll(".trix-dialog").forEach((dialog) => { + applyClasses(dialog, richTextToolbarClasses.dialog) + }) + + toolbar.querySelectorAll(".trix-input").forEach((input) => { + applyClasses(input, richTextToolbarClasses.input) + }) +} + +document.addEventListener("trix-initialize", (event) => { + const editor = event.target + if (editor.dataset.tramwayRichTextArea !== "true") return + + styleRichTextToolbar(editor) +}) + class TramwaySelect extends Controller { static targets = ["dropdown", "showSelectedArea", "hiddenInput", "caretDown", "caretUp"] diff --git a/app/components/tramway/form/builder.rb b/app/components/tramway/form/builder.rb index 90237f08..dd3eaaed 100644 --- a/app/components/tramway/form/builder.rb +++ b/app/components/tramway/form/builder.rb @@ -10,6 +10,31 @@ class Builder < Tramway::Views::FormBuilder include Tramway::Utils::Field include Tramway::ColorsMethods + RICH_TEXT_AREA_CLASSES = [ + 'trix-content', + 'prose', + 'prose-invert', + 'max-w-none', + 'w-full', + 'min-h-40', + 'rounded-md', + 'border', + 'border-zinc-800', + 'bg-zinc-950', + '!bg-zinc-950', + 'text-zinc-50', + '!text-zinc-50', + 'shadow-sm', + 'transition-colors', + 'focus-visible:outline-none', + 'focus-visible:ring-2', + 'focus-visible:ring-zinc-300', + 'focus-visible:ring-offset-2', + 'focus-visible:ring-offset-zinc-950', + 'disabled:cursor-not-allowed', + 'disabled:opacity-50' + ].freeze + def initialize(object_name, object, template, options) @horizontal = options[:horizontal] || false @remote = options[:remote_submit] || false @@ -56,6 +81,16 @@ def text_area(attribute, **, &) common_field(:text_area, :text_area, attribute, **, &) end + def rich_text_area(attribute, **, &) + rich_textarea(attribute, **, &) + end + + def rich_textarea(attribute, **options, &) + sanitized_options = sanitize_options(options) + + super(attribute, rich_text_area_options(sanitized_options), &) + end + def password_field(attribute, **options, &) sanitized_options = sanitize_options(options) @@ -217,6 +252,13 @@ def sanitize_options(options) end end + def rich_text_area_options(options) + custom_class = options.delete(:class) || options.delete('class') + data = (options.delete(:data) || options.delete('data') || {}).merge(tramway_rich_text_area: true) + + options.merge(class: [RICH_TEXT_AREA_CLASSES, custom_class].compact.join(' '), data:) + end + # REMOVE IT. WE MUST UNDERSTAND WHY INCLUDE_BLANK DOES NOT WORK def explicitly_add_blank_option(collection, options) if options[:include_blank] diff --git a/app/controllers/tramway/entities_controller.rb b/app/controllers/tramway/entities_controller.rb index dd37e98e..f401375c 100644 --- a/app/controllers/tramway/entities_controller.rb +++ b/app/controllers/tramway/entities_controller.rb @@ -37,10 +37,12 @@ def show def new @record = tramway_form model_class.new, namespace: entity.namespace + @form_title = @record.object.model_name.human end def edit @record = tramway_form model_class.find(params[:id]), namespace: entity.namespace + @form_title = @record.form_title end # rubocop:disable Metrics/AbcSize @@ -50,6 +52,7 @@ def create if @record.submit params[model_class.model_name.param_key] redirect_to public_send(entity.show_helper_method, @record.id), notice: t('tramway.notices.created') else + @form_title = @record.object.model_name.human render :new end end @@ -60,6 +63,7 @@ def update if @record.submit params[model_class.model_name.param_key] redirect_to public_send(entity.show_helper_method, @record.id), notice: t('tramway.notices.updated') else + @form_title = @record.form_title render :edit end end diff --git a/app/views/tramway/entities/edit.html.haml b/app/views/tramway/entities/edit.html.haml index 6b944983..0f85708f 100644 --- a/app/views/tramway/entities/edit.html.haml +++ b/app/views/tramway/entities/edit.html.haml @@ -1,5 +1,5 @@ = tramway_container do - = tramway_title text: t('tramway.pages.edit.title', model_name: @record.object.model_name.human) + = tramway_title text: t('tramway.pages.edit.title', model_name: @form_title) .mt-4 = render 'form' diff --git a/app/views/tramway/entities/new.html.haml b/app/views/tramway/entities/new.html.haml index a924c6e5..28f10b7c 100644 --- a/app/views/tramway/entities/new.html.haml +++ b/app/views/tramway/entities/new.html.haml @@ -1,5 +1,5 @@ = tramway_container do - = tramway_title text: t('tramway.pages.new.title', model_name: @record.object.model_name.human) + = tramway_title text: t('tramway.pages.new.title', model_name: @form_title) .mt-4 = render 'form' diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 59f2d9ab..e23beeb9 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -12,6 +12,7 @@ module.exports = { 'md:left-auto', 'md:translate-x-0', 'border-white', + '!border-zinc-800', 'h-screen', 'w-screen', 'z-50', @@ -27,6 +28,7 @@ module.exports = { 'pt-16', 'min-h-8', 'bg-zinc-950', + '!bg-zinc-950', 'bg-zinc-950/95', 'bg-zinc-950/80', 'bg-zinc-900', @@ -34,6 +36,7 @@ module.exports = { 'bg-zinc-900/80', 'border-zinc-800', 'text-zinc-50', + '!text-zinc-50', 'text-zinc-100', 'text-zinc-200', 'text-zinc-400', @@ -41,6 +44,7 @@ module.exports = { 'placeholder:text-zinc-500', 'hover:bg-zinc-800', 'hover:bg-zinc-900', + 'hover:!bg-zinc-900', 'hover:text-zinc-50', 'focus-visible:ring-zinc-300', 'focus-visible:ring-zinc-400', @@ -221,6 +225,9 @@ module.exports = { 'hidden', 'text-xl', 'font-bold', + 'prose', + 'prose-invert', + 'max-w-none', // === Button base shell === 'inline-flex', @@ -338,6 +345,7 @@ module.exports = { 'min-h-10', 'min-h-12', 'min-h-15', + 'min-h-40', 'max-w-full', // === Spacing utilities === @@ -411,6 +419,7 @@ module.exports = { 'h-12', 'border-transparent', 'focus:ring-2', + 'focus:ring-zinc-300', 'focus:ring-zinc-400', 'focus:ring-offset-2', 'focus:ring-offset-zinc-950', diff --git a/lib/tramway/base_form.rb b/lib/tramway/base_form.rb index f329d56c..716972cf 100644 --- a/lib/tramway/base_form.rb +++ b/lib/tramway/base_form.rb @@ -60,6 +60,12 @@ def assign(params) __submit params end + def form_title + return object.title if object.respond_to?(:title) && object.title.present? + + object.model_name.human + end + def method_missing(method_name, *args) if method_name.to_s.end_with?('=') && args.one? object.public_send(method_name, args.first) diff --git a/lib/tramway/utils/field.rb b/lib/tramway/utils/field.rb index b43092fd..bd7dab6d 100644 --- a/lib/tramway/utils/field.rb +++ b/lib/tramway/utils/field.rb @@ -23,7 +23,7 @@ def tramway_field(field_data, attribute, **options, &) def field_name(field_data) case field_data.to_sym - when :text_area, :select, :tramway_select, :check_box + when :text_area, :rich_text_area, :rich_textarea, :select, :tramway_select, :check_box field_data when :checkbox :check_box diff --git a/spec/components/tramway/form/builder_spec.rb b/spec/components/tramway/form/builder_spec.rb index 6c11049a..5f0b0623 100644 --- a/spec/components/tramway/form/builder_spec.rb +++ b/spec/components/tramway/form/builder_spec.rb @@ -38,6 +38,21 @@ ] }.freeze +RICH_TEXT_AREA_CLASSES = %w[ + trix-content prose prose-invert w-full min-h-40 rounded-md border border-zinc-800 bg-zinc-950 !bg-zinc-950 + text-zinc-50 !text-zinc-50 focus-visible:ring-zinc-300 focus-visible:ring-offset-zinc-950 +].freeze + +module DefaultRichTextAreaFormBuilder + def rich_textarea(attribute, options = {}, &) + template.content_tag('trix-editor', '', options.merge(input: attribute), &) + end + + alias rich_text_area rich_textarea +end + +Tramway::Views::FormBuilder.prepend(DefaultRichTextAreaFormBuilder) + describe Tramway::Form::Builder, type: :view do let(:resource) { build :user } let(:form_options) { {} } @@ -275,6 +290,49 @@ end end + describe '#rich_text_area' do + let(:output) { builder.rich_text_area :personal_info } + let(:editor) { Capybara.string(output).find('trix-editor') } + + it 'calls the default rich text area helper' do + expect(output).to have_selector 'trix-editor[input="personal_info"]' + end + + it 'renders rich text input classes matching Tramway form colors' do + expect(editor[:class].split).to include(*RICH_TEXT_AREA_CLASSES) + end + + it 'marks Trix editors for Tramway toolbar styling' do + expect(output).to have_selector 'trix-editor[data-tramway-rich-text-area="true"]' + end + + context 'with custom classes and unsupported Tramway sizing option' do + let(:output) { builder.rich_text_area :personal_info, class: 'custom-rich-text', size: :large } + + it 'preserves custom classes' do + expect(output).to have_selector 'trix-editor.custom-rich-text' + end + + it 'does not pass the Tramway sizing option to Action Text' do + expect(output).not_to have_selector 'trix-editor[size]' + end + + it 'preserves custom data attributes' do + output = builder.rich_text_area :personal_info, data: { controller: 'mentions' } + + expect(output).to have_selector 'trix-editor[data-controller="mentions"][data-tramway-rich-text-area="true"]' + end + end + end + + describe '#tramway_field' do + let(:output) { builder.tramway_field(:rich_text_area, :personal_info) } + + it 'renders rich text areas from field definitions' do + expect(output).to have_selector 'trix-editor[input="personal_info"]' + end + end + describe '#file_field' do let(:output) do builder.file_field :file diff --git a/spec/features/entities/update_spec.rb b/spec/features/entities/update_spec.rb index 9cba83eb..ab86261a 100644 --- a/spec/features/entities/update_spec.rb +++ b/spec/features/entities/update_spec.rb @@ -32,7 +32,7 @@ click_button 'Edit' - expect(page).to have_content('Edit Post') + expect(page).to have_content('Edit Original Post') expect(page).to have_field('post[title]', with: 'Original Post') expect(page).to have_field('post[text]', with: 'Original text') expect(page).to have_field('post[user_id]', type: :hidden, with: User.first.id) diff --git a/spec/forms/base_form_spec.rb b/spec/forms/base_form_spec.rb index f87ce7cc..1d631408 100644 --- a/spec/forms/base_form_spec.rb +++ b/spec/forms/base_form_spec.rb @@ -73,6 +73,12 @@ end end + describe '#form_title' do + it 'falls back to model name when object has no title' do + expect(form_object.form_title).to eq('User') + end + end + context 'with method delegation' do it 'delegates certain methods to the object' do methods_to_delegate = %i[id to_key errors attributes] @@ -143,5 +149,13 @@ expect(form).to respond_to(:to_model) end + + describe '#form_title' do + it 'returns object title when present' do + form = Admin::PostForm.new(build(:post, title: 'Original Post')) + + expect(form.form_title).to eq('Original Post') + end + end end # rubocop:enable RSpec/SpecFilePathFormat