From a832e461ae4f2ebe7b2d3ade6af9cd9fdd90b817 Mon Sep 17 00:00:00 2001 From: kengallego Date: Thu, 21 May 2026 11:39:28 +0200 Subject: [PATCH 1/4] Add modal component --- app/assets/stylesheets/essence/beyond.css | 1 + .../essence/beyond/components/modal.css | 82 ++++++++++ app/assets/stylesheets/essence/now.css | 1 + .../essence/now/components/modal.css | 82 ++++++++++ app/components/essence/modal_component.rb | 48 ++++++ .../modal_component/modal_component.html.erb | 22 +++ .../modal_component_controller.js | 23 +++ app/javascript/essence/controllers/index.js | 3 + .../essence/modal_component_spec.rb | 150 ++++++++++++++++++ 9 files changed, 412 insertions(+) create mode 100644 app/assets/stylesheets/essence/beyond/components/modal.css create mode 100644 app/assets/stylesheets/essence/now/components/modal.css create mode 100644 app/components/essence/modal_component.rb create mode 100644 app/components/essence/modal_component/modal_component.html.erb create mode 100644 app/components/essence/modal_component/modal_component_controller.js create mode 100644 spec/components/essence/modal_component_spec.rb diff --git a/app/assets/stylesheets/essence/beyond.css b/app/assets/stylesheets/essence/beyond.css index 490ba02..91d2174 100644 --- a/app/assets/stylesheets/essence/beyond.css +++ b/app/assets/stylesheets/essence/beyond.css @@ -28,6 +28,7 @@ @import url("beyond/components/expandable_toggle.css"); @import url("beyond/components/flash.css"); @import url("beyond/components/link.css"); +@import url("beyond/components/modal.css"); @import url("beyond/components/notification.css"); @import url("beyond/components/paragraph.css"); @import url("beyond/components/scroll_shadow.css"); diff --git a/app/assets/stylesheets/essence/beyond/components/modal.css b/app/assets/stylesheets/essence/beyond/components/modal.css new file mode 100644 index 0000000..cafbbc4 --- /dev/null +++ b/app/assets/stylesheets/essence/beyond/components/modal.css @@ -0,0 +1,82 @@ +.modal { + align-items: center; + background-color: rgba(0, 0, 0, 0.75); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 99999; + + .modal__dialog { + margin: 25px; + max-width: 600px; + width: 100%; + } + + .modal__content { + background: var(--notification-background, white); + border-radius: var(--notification-borderRadius, 4px); + box-shadow: var(--notification-boxShadow, 0 1px 2px 0 rgba(0, 0, 0, 0.2)); + box-sizing: border-box; + padding: 40px; + position: relative; + } + + .modal__header { + margin-bottom: 30px; + } + + .modal__title { + color: var(--notification-text, gray); + font-size: 24px; + font-weight: normal; + line-height: 1.2; + margin: 0; + } + + .modal__close { + background: none; + border: 0; + color: #8b8b8b; + cursor: pointer; + padding: 0; + position: absolute; + right: 20px; + top: 20px; + + &:hover { + color: #717171; + } + } + + .modal__body { + max-height: 65vh; + overflow-x: hidden; + padding-right: 20px; + } + + .modal__footer { + display: flex; + flex-direction: row; + margin-top: 35px; + + &.modal__footer--start { + justify-content: flex-start; + } + + &.modal__footer--center { + justify-content: center; + } + + &.modal__footer--end { + justify-content: flex-end; + } + + &.modal__footer--space-between { + justify-content: space-between; + } + } +} diff --git a/app/assets/stylesheets/essence/now.css b/app/assets/stylesheets/essence/now.css index 07e2b51..4480981 100644 --- a/app/assets/stylesheets/essence/now.css +++ b/app/assets/stylesheets/essence/now.css @@ -2,4 +2,5 @@ @import url("now/components/button.css"); @import url("now/components/card.css"); +@import url("now/components/modal.css"); @import url("now/components/title.css"); diff --git a/app/assets/stylesheets/essence/now/components/modal.css b/app/assets/stylesheets/essence/now/components/modal.css new file mode 100644 index 0000000..cafbbc4 --- /dev/null +++ b/app/assets/stylesheets/essence/now/components/modal.css @@ -0,0 +1,82 @@ +.modal { + align-items: center; + background-color: rgba(0, 0, 0, 0.75); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 99999; + + .modal__dialog { + margin: 25px; + max-width: 600px; + width: 100%; + } + + .modal__content { + background: var(--notification-background, white); + border-radius: var(--notification-borderRadius, 4px); + box-shadow: var(--notification-boxShadow, 0 1px 2px 0 rgba(0, 0, 0, 0.2)); + box-sizing: border-box; + padding: 40px; + position: relative; + } + + .modal__header { + margin-bottom: 30px; + } + + .modal__title { + color: var(--notification-text, gray); + font-size: 24px; + font-weight: normal; + line-height: 1.2; + margin: 0; + } + + .modal__close { + background: none; + border: 0; + color: #8b8b8b; + cursor: pointer; + padding: 0; + position: absolute; + right: 20px; + top: 20px; + + &:hover { + color: #717171; + } + } + + .modal__body { + max-height: 65vh; + overflow-x: hidden; + padding-right: 20px; + } + + .modal__footer { + display: flex; + flex-direction: row; + margin-top: 35px; + + &.modal__footer--start { + justify-content: flex-start; + } + + &.modal__footer--center { + justify-content: center; + } + + &.modal__footer--end { + justify-content: flex-end; + } + + &.modal__footer--space-between { + justify-content: space-between; + } + } +} diff --git a/app/components/essence/modal_component.rb b/app/components/essence/modal_component.rb new file mode 100644 index 0000000..46d0334 --- /dev/null +++ b/app/components/essence/modal_component.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Essence + class ModalComponent < ApplicationComponent + attr_reader :title, :footer_alignment, :title_id, :dismiss_icon, :html_options + + FOOTER_ALIGNMENT_OPTIONS = ['start', 'center', 'end', 'space-between'].freeze + DEFAULT_FOOTER_ALIGNMENT = 'end' + + renders_one :footer + + def initialize(title:, + dismiss_icon: false, + dismiss_keyup: false, + dismiss_click: false, + dismiss_submit: false, + footer_alignment: DEFAULT_FOOTER_ALIGNMENT, + **html_options) + @title = title + @dismiss_icon = dismiss_icon + @dismiss_keyup = dismiss_keyup + @dismiss_click = dismiss_click + @dismiss_submit = dismiss_submit + @footer_alignment = fetch_or_fallback(FOOTER_ALIGNMENT_OPTIONS, footer_alignment, DEFAULT_FOOTER_ALIGNMENT) + @title_id = "modal-title-#{object_id}" + @html_options = html_options + end + + private + + def before_render + set_base_html_options( + 'modal', + role: 'dialog', + aria: { modal: 'true', labelledby: title_id }, + data: { controller: 'modal', action: modal_actions }.compact + ) + end + + def modal_actions + actions = [] + actions << 'keyup@window->modal#closeWithKeyboard' if @dismiss_keyup + actions << 'click@window->modal#closeBackground' if @dismiss_click + actions << 'turbo:submit-end->modal#submitEnd' if @dismiss_submit + actions.join(' ').presence + end + end +end diff --git a/app/components/essence/modal_component/modal_component.html.erb b/app/components/essence/modal_component/modal_component.html.erb new file mode 100644 index 0000000..548b646 --- /dev/null +++ b/app/components/essence/modal_component/modal_component.html.erb @@ -0,0 +1,22 @@ +<%= helpers.turbo_frame_tag 'modal' do %> +
> + +
+<% end %> diff --git a/app/components/essence/modal_component/modal_component_controller.js b/app/components/essence/modal_component/modal_component_controller.js new file mode 100644 index 0000000..a2265b4 --- /dev/null +++ b/app/components/essence/modal_component/modal_component_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["modalContent"] + + hideModal() { + const frame = this.element.closest("turbo-frame") + if (frame) frame.removeAttribute("src") + this.element.remove() + } + + closeWithKeyboard(e) { + if (e.key === "Escape") this.hideModal() + } + + closeBackground(e) { + if (!this.modalContentTarget.contains(e.target)) this.hideModal() + } + + submitEnd(e) { + if (e.detail.success) this.hideModal() + } +} diff --git a/app/javascript/essence/controllers/index.js b/app/javascript/essence/controllers/index.js index e519aa5..e16d85c 100644 --- a/app/javascript/essence/controllers/index.js +++ b/app/javascript/essence/controllers/index.js @@ -20,6 +20,9 @@ application.register('expandable-toggle', ExpandableToggleComponentController) import FlashComponentController from 'components/essence/flash_component/flash_component_controller' application.register('flash', FlashComponentController) +import ModalComponentController from 'components/essence/modal_component/modal_component_controller' +application.register('modal', ModalComponentController) + import ParagraphComponentController from 'components/essence/paragraph_component/paragraph_component_controller' application.register('paragraph', ParagraphComponentController) diff --git a/spec/components/essence/modal_component_spec.rb b/spec/components/essence/modal_component_spec.rb new file mode 100644 index 0000000..ca6d9e3 --- /dev/null +++ b/spec/components/essence/modal_component_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Essence::ModalComponent, type: :component do + let(:title) { 'Confirm action' } + + def build(footer_alignment: 'end', **kwargs, &block) + render_inline(described_class.new(title:, **kwargs), &block) + end + + describe 'basic rendering' do + it 'renders with required title' do + build + expect(page).to have_css '.modal__title', text: title + end + + it 'renders arbitrary body content' do + build { 'Hello, modal!' } + expect(page).to have_css '.modal__body', text: 'Hello, modal!' + end + end + + describe 'turbo frame wrapper' do + it 'has id="modal"' do + build + expect(page).to have_css 'turbo-frame#modal' + end + end + + describe 'accessibility' do + it 'applies role="dialog"' do + build + expect(page).to have_css '.modal[role="dialog"]' + end + + it 'applies aria-modal="true"' do + build + expect(page).to have_css '.modal[aria-modal="true"]' + end + + it 'aria-labelledby points to the title element id' do + build + labelledby = page.find('.modal')['aria-labelledby'] + expect(page).to have_css "##{labelledby}.modal__title", text: title + end + end + + describe 'dismiss_icon' do + context 'when false (default)' do + it 'does not render the close button' do + build + expect(page).to have_no_css '.modal__close' + end + end + + context 'when true' do + it 'renders the close button with click->modal#hideModal action' do + build(dismiss_icon: true) + expect(page).to have_css '.modal__close[data-action*="click->modal#hideModal"]' + end + end + end + + describe 'dismiss_keyup' do + context 'when true' do + it 'includes keyup@window->modal#closeWithKeyboard in data-action' do + build(dismiss_keyup: true) + expect(page).to have_css '.modal[data-action*="keyup@window->modal#closeWithKeyboard"]' + end + end + end + + describe 'dismiss_click' do + context 'when true' do + it 'includes click@window->modal#closeBackground in data-action' do + build(dismiss_click: true) + expect(page).to have_css '.modal[data-action*="click@window->modal#closeBackground"]' + end + end + end + + describe 'dismiss_submit' do + context 'when true' do + it 'includes turbo:submit-end->modal#submitEnd in data-action' do + build(dismiss_submit: true) + expect(page).to have_css '.modal[data-action*="turbo:submit-end->modal#submitEnd"]' + end + end + end + + describe 'all dismisses off (default)' do + it 'data-action is absent or empty' do + build + node = page.find('.modal') + action = node['data-action'] + expect(action.to_s.strip).to be_empty + end + end + + describe 'footer slot' do + it 'is absent by default' do + build + expect(page).to have_no_css '.modal__footer' + end + + it 'renders when provided via slot' do + render_inline(described_class.new(title:)) do |c| + c.with_footer { 'Cancel OK' } + end + expect(page).to have_css '.modal__footer', text: 'Cancel OK' + end + + context 'footer_alignment' do + it "adds modal__footer--start when 'start'" do + render_inline(described_class.new(title:, footer_alignment: 'start')) do |c| + c.with_footer { 'Footer' } + end + expect(page).to have_css '.modal__footer.modal__footer--start' + end + + it "adds modal__footer--center when 'center'" do + render_inline(described_class.new(title:, footer_alignment: 'center')) do |c| + c.with_footer { 'Footer' } + end + expect(page).to have_css '.modal__footer.modal__footer--center' + end + + it "adds modal__footer--end when 'end' (default)" do + render_inline(described_class.new(title:, footer_alignment: 'end')) do |c| + c.with_footer { 'Footer' } + end + expect(page).to have_css '.modal__footer.modal__footer--end' + end + + it "adds modal__footer--space-between when 'space-between'" do + render_inline(described_class.new(title:, footer_alignment: 'space-between')) do |c| + c.with_footer { 'Footer' } + end + expect(page).to have_css '.modal__footer.modal__footer--space-between' + end + + it 'raises InvalidValueError on invalid alignment (raises in local/test env, fallbacks in production)' do + expect { + described_class.new(title:, footer_alignment: 'invalid') + }.to raise_error(Essence::FetchOrFallbackHelper::InvalidValueError) + end + end + end +end From 2583e18931798011cb1850d56f3e15b97b9590d1 Mon Sep 17 00:00:00 2001 From: kengallego Date: Thu, 21 May 2026 12:14:41 +0200 Subject: [PATCH 2/4] Fix rubocop offenses --- spec/components/essence/modal_component_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/components/essence/modal_component_spec.rb b/spec/components/essence/modal_component_spec.rb index ca6d9e3..94c2fed 100644 --- a/spec/components/essence/modal_component_spec.rb +++ b/spec/components/essence/modal_component_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Essence::ModalComponent, type: :component do let(:title) { 'Confirm action' } - def build(footer_alignment: 'end', **kwargs, &block) + def build(**kwargs, &block) render_inline(described_class.new(title:, **kwargs), &block) end @@ -141,9 +141,9 @@ def build(footer_alignment: 'end', **kwargs, &block) end it 'raises InvalidValueError on invalid alignment (raises in local/test env, fallbacks in production)' do - expect { + expect do described_class.new(title:, footer_alignment: 'invalid') - }.to raise_error(Essence::FetchOrFallbackHelper::InvalidValueError) + end.to raise_error(Essence::FetchOrFallbackHelper::InvalidValueError) end end end From 758986b9ee8c13f6beb443c190eb3629c79b91fc Mon Sep 17 00:00:00 2001 From: kengallego Date: Thu, 21 May 2026 14:53:08 +0200 Subject: [PATCH 3/4] Improve styles --- .../essence/beyond/components/modal.css | 34 ++++++++----- .../essence/now/components/modal.css | 50 ++++++++++++------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/app/assets/stylesheets/essence/beyond/components/modal.css b/app/assets/stylesheets/essence/beyond/components/modal.css index cafbbc4..b746ede 100644 --- a/app/assets/stylesheets/essence/beyond/components/modal.css +++ b/app/assets/stylesheets/essence/beyond/components/modal.css @@ -12,35 +12,36 @@ .modal__dialog { margin: 25px; - max-width: 600px; + max-width: 560px; width: 100%; } .modal__content { - background: var(--notification-background, white); - border-radius: var(--notification-borderRadius, 4px); - box-shadow: var(--notification-boxShadow, 0 1px 2px 0 rgba(0, 0, 0, 0.2)); + background: #ffffff; + border-radius: 3px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); box-sizing: border-box; - padding: 40px; + padding: 32px; position: relative; } .modal__header { - margin-bottom: 30px; + margin-bottom: 24px; } .modal__title { - color: var(--notification-text, gray); - font-size: 24px; - font-weight: normal; + color: #333333; + font-size: 22px; + font-weight: 600; line-height: 1.2; margin: 0; + padding-right: 28px; } .modal__close { background: none; border: 0; - color: #8b8b8b; + color: #999999; cursor: pointer; padding: 0; position: absolute; @@ -48,20 +49,27 @@ top: 20px; &:hover { - color: #717171; + color: #555555; + } + + svg { + display: block; + fill: currentcolor; + height: 13px; + width: 13px; } } .modal__body { max-height: 65vh; overflow-x: hidden; - padding-right: 20px; + overflow-y: auto; } .modal__footer { display: flex; flex-direction: row; - margin-top: 35px; + margin-top: 28px; &.modal__footer--start { justify-content: flex-start; diff --git a/app/assets/stylesheets/essence/now/components/modal.css b/app/assets/stylesheets/essence/now/components/modal.css index cafbbc4..1fbc739 100644 --- a/app/assets/stylesheets/essence/now/components/modal.css +++ b/app/assets/stylesheets/essence/now/components/modal.css @@ -1,67 +1,79 @@ .modal { - align-items: center; - background-color: rgba(0, 0, 0, 0.75); + align-items: flex-start; + background-color: rgba(0, 0, 0, 0.3); bottom: 0; display: flex; justify-content: center; left: 0; + padding-top: 60px; position: fixed; right: 0; top: 0; z-index: 99999; .modal__dialog { - margin: 25px; - max-width: 600px; + margin: 0 25px; + max-width: 700px; width: 100%; } .modal__content { - background: var(--notification-background, white); - border-radius: var(--notification-borderRadius, 4px); - box-shadow: var(--notification-boxShadow, 0 1px 2px 0 rgba(0, 0, 0, 0.2)); + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); box-sizing: border-box; - padding: 40px; + padding: 24px; position: relative; } .modal__header { - margin-bottom: 30px; + margin-bottom: 12px; + min-height: 16px; } .modal__title { - color: var(--notification-text, gray); - font-size: 24px; - font-weight: normal; - line-height: 1.2; + color: #333333; + font-size: 18px; + font-weight: 600; + line-height: 1.3; margin: 0; + padding-right: 24px; } .modal__close { background: none; border: 0; - color: #8b8b8b; + color: #999999; cursor: pointer; + line-height: 1; padding: 0; position: absolute; - right: 20px; - top: 20px; + right: 16px; + top: 16px; &:hover { - color: #717171; + color: #555555; + } + + svg { + display: block; + fill: currentcolor; + height: 14px; + width: 14px; } } .modal__body { max-height: 65vh; overflow-x: hidden; - padding-right: 20px; + overflow-y: auto; } .modal__footer { display: flex; flex-direction: row; - margin-top: 35px; + margin-top: 20px; &.modal__footer--start { justify-content: flex-start; From de23d80de0e8d60e7012b4b414a1f8fa4c3b969a Mon Sep 17 00:00:00 2001 From: kengallego Date: Tue, 26 May 2026 15:16:27 +0200 Subject: [PATCH 4/4] Add missing helper --- .../clipboard_copy_component/clipboard_copy_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/essence/clipboard_copy_component/clipboard_copy_component.html.erb b/app/components/essence/clipboard_copy_component/clipboard_copy_component.html.erb index 688c2ac..1f9018e 100644 --- a/app/components/essence/clipboard_copy_component/clipboard_copy_component.html.erb +++ b/app/components/essence/clipboard_copy_component/clipboard_copy_component.html.erb @@ -1,5 +1,5 @@
> - <%= simple_fields_for nil do |f| %> + <%= helpers.simple_fields_for nil do |f| %> <%= f.input input_name, required: false, readonly: true,