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..b746ede --- /dev/null +++ b/app/assets/stylesheets/essence/beyond/components/modal.css @@ -0,0 +1,90 @@ +.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: 560px; + width: 100%; + } + + .modal__content { + background: #ffffff; + border-radius: 3px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); + box-sizing: border-box; + padding: 32px; + position: relative; + } + + .modal__header { + margin-bottom: 24px; + } + + .modal__title { + color: #333333; + font-size: 22px; + font-weight: 600; + line-height: 1.2; + margin: 0; + padding-right: 28px; + } + + .modal__close { + background: none; + border: 0; + color: #999999; + cursor: pointer; + padding: 0; + position: absolute; + right: 20px; + top: 20px; + + &:hover { + color: #555555; + } + + svg { + display: block; + fill: currentcolor; + height: 13px; + width: 13px; + } + } + + .modal__body { + max-height: 65vh; + overflow-x: hidden; + overflow-y: auto; + } + + .modal__footer { + display: flex; + flex-direction: row; + margin-top: 28px; + + &.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..1fbc739 --- /dev/null +++ b/app/assets/stylesheets/essence/now/components/modal.css @@ -0,0 +1,94 @@ +.modal { + 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: 0 25px; + max-width: 700px; + width: 100%; + } + + .modal__content { + 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: 24px; + position: relative; + } + + .modal__header { + margin-bottom: 12px; + min-height: 16px; + } + + .modal__title { + color: #333333; + font-size: 18px; + font-weight: 600; + line-height: 1.3; + margin: 0; + padding-right: 24px; + } + + .modal__close { + background: none; + border: 0; + color: #999999; + cursor: pointer; + line-height: 1; + padding: 0; + position: absolute; + right: 16px; + top: 16px; + + &:hover { + color: #555555; + } + + svg { + display: block; + fill: currentcolor; + height: 14px; + width: 14px; + } + } + + .modal__body { + max-height: 65vh; + overflow-x: hidden; + overflow-y: auto; + } + + .modal__footer { + display: flex; + flex-direction: row; + margin-top: 20px; + + &.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/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, 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..94c2fed --- /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(**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 do + described_class.new(title:, footer_alignment: 'invalid') + end.to raise_error(Essence::FetchOrFallbackHelper::InvalidValueError) + end + end + end +end