From b3a23bd9d6abdff2d87d4f32866a27cb6770f499 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 3 Jul 2023 10:51:27 +0100 Subject: [PATCH] DEV: Introduce new component-based DModal API (#21304) Ember 4.x will be removing the 'named outlet' feature, which were previously relying on to render modal 'controllers' and their associated templates. This commit updates the modal.show API to accept a component class, and also introduces a declarative API which can be used by including the modal-legacy-test.js} | 2 +- .../tests/acceptance/modal-service-test.js | 113 ++++++++ .../integration/components/d-modal-test.js | 75 ++++++ .../components/sections/organisms/modal.hbs | 68 ++++- .../components/sections/organisms/modal.js | 39 +++ .../javascripts/discourse/lib/dummy-data.js | 2 + 19 files changed, 922 insertions(+), 244 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/conditional-in-element.hbs create mode 100644 app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs create mode 100644 app/assets/javascripts/discourse/app/components/d-modal-legacy.js rename app/assets/javascripts/discourse/tests/acceptance/{modal-test.js => modal-legacy-test.js} (99%) create mode 100644 app/assets/javascripts/discourse/tests/acceptance/modal-service-test.js create mode 100644 app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js create mode 100644 plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.js diff --git a/app/assets/javascripts/discourse/app/components/conditional-in-element.hbs b/app/assets/javascripts/discourse/app/components/conditional-in-element.hbs new file mode 100644 index 00000000000..699f907e802 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/conditional-in-element.hbs @@ -0,0 +1,9 @@ +{{#if @inline}} + {{yield}} +{{else if @element}} + {{#if @append}} + {{#in-element @element insertBefore=null}}{{yield}}{{/in-element}} + {{else}} + {{#in-element @element}}{{yield}}{{/in-element}} + {{/if}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.hbs b/app/assets/javascripts/discourse/app/components/d-modal-body.hbs index 3129bd7e431..79681ae73bf 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal-body.hbs +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.hbs @@ -1,3 +1,5 @@ +{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }} + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/d-modal-legacy.js b/app/assets/javascripts/discourse/app/components/d-modal-legacy.js new file mode 100644 index 00000000000..ef8da04ba41 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-modal-legacy.js @@ -0,0 +1,253 @@ +// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) + +import Component from "@glimmer/component"; +import I18n from "I18n"; +import { next, schedule } from "@ember/runloop"; +import { bind } from "discourse-common/utils/decorators"; +import { disableImplicitInjections } from "discourse/lib/implicit-injections"; +import { inject as service } from "@ember/service"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; + +@disableImplicitInjections +export default class DModal extends Component { + @service appEvents; + @service modal; + + @tracked wrapperElement; + @tracked modalBodyData = {}; + @tracked flash; + + get modalStyle() { + if (this.args.modalStyle === "inline-modal") { + return "inline-modal"; + } else { + return "fixed-modal"; + } + } + + get submitOnEnter() { + if ("submitOnEnter" in this.modalBodyData) { + return this.modalBodyData.submitOnEnter; + } else { + return true; + } + } + + get dismissable() { + if ("dismissable" in this.modalBodyData) { + return this.modalBodyData.dismissable; + } else { + return true; + } + } + + get title() { + if (this.modalBodyData.title) { + return I18n.t(this.modalBodyData.title); + } else if (this.modalBodyData.rawTitle) { + return this.modalBodyData.rawTitle; + } else { + return this.args.title; + } + } + + get subtitle() { + if (this.modalBodyData.subtitle) { + return I18n.t(this.modalBodyData.subtitle); + } + + return this.modalBodyData.rawSubtitle || this.args.subtitle; + } + + get headerClass() { + return this.modalBodyData.headerClass; + } + + get panels() { + return this.args.panels; + } + + get errors() { + return this.args.errors; + } + + @action + setupListeners(element) { + this.appEvents.on("modal:body-shown", this._modalBodyShown); + this.appEvents.on("modal-body:flash", this._flash); + this.appEvents.on("modal-body:clearFlash", this._clearFlash); + document.documentElement.addEventListener( + "keydown", + this._handleModalEvents + ); + this.wrapperElement = element; + } + + @action + cleanupListeners() { + this.appEvents.off("modal:body-shown", this._modalBodyShown); + this.appEvents.off("modal-body:flash", this._flash); + this.appEvents.off("modal-body:clearFlash", this._clearFlash); + document.documentElement.removeEventListener( + "keydown", + this._handleModalEvents + ); + } + + get ariaLabelledby() { + if (this.modalBodyData.titleAriaElementId) { + return this.modalBodyData.titleAriaElementId; + } else if (this.args.titleAriaElementId) { + return this.args.titleAriaElementId; + } else if (this.args.title) { + return "discourse-modal-title"; + } + } + + get modalClass() { + return this.modalBodyData.modalClass || this.args.modalClass; + } + + triggerClickOnEnter(e) { + if (!this.submitOnEnter) { + return false; + } + + // skip when in a form or a textarea element + if ( + e.target.closest("form") || + (document.activeElement && document.activeElement.nodeName === "TEXTAREA") + ) { + return false; + } + + return true; + } + + @action + handleMouseDown(e) { + if (!this.dismissable) { + return; + } + + if ( + e.target.classList.contains("modal-middle-container") || + e.target.classList.contains("modal-outer-container") + ) { + // Send modal close (which bubbles to ApplicationRoute) if clicked outside. + // We do this because some CSS of ours seems to cover the backdrop and makes + // it unclickable. + return this.args.closeModal?.("initiatedByClickOut"); + } + } + + @bind + _modalBodyShown(data) { + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (data.fixed) { + this.modal.hidden = false; + } + + this.modalBodyData = data; + + next(() => { + schedule("afterRender", () => { + this._trapTab(); + }); + }); + } + + @bind + _handleModalEvents(event) { + if (this.args.hidden) { + return; + } + + if (event.key === "Escape" && this.dismissable) { + next(() => this.args.closeModal("initiatedByESC")); + } + + if (event.key === "Enter" && this.triggerClickOnEnter(event)) { + this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click(); + event.preventDefault(); + } + + if (event.key === "Tab") { + this._trapTab(event); + } + } + + _trapTab(event) { + if (this.args.hidden) { + return true; + } + + const innerContainer = this.wrapperElement.querySelector( + ".modal-inner-container" + ); + if (!innerContainer) { + return; + } + + let focusableElements = + '[autofocus], a, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'; + + if (!event) { + // on first trap we don't allow to focus modal-close + // and apply manual focus only if we don't have any autofocus element + const autofocusedElement = innerContainer.querySelector("[autofocus]"); + if ( + !autofocusedElement || + document.activeElement !== autofocusedElement + ) { + // if there's not autofocus, or the activeElement, is not the autofocusable element + // attempt to focus the first of the focusable elements or just the modal-body + // to make it possible to scroll with arrow down/up + ( + autofocusedElement || + innerContainer.querySelector( + focusableElements + ", button:not(.modal-close)" + ) || + innerContainer.querySelector(".modal-body") + )?.focus(); + } + + return; + } + + focusableElements += ", button:enabled"; + + const firstFocusableElement = + innerContainer.querySelector(focusableElements); + const focusableContent = innerContainer.querySelectorAll(focusableElements); + const lastFocusableElement = focusableContent[focusableContent.length - 1]; + + if (event.shiftKey) { + if (document.activeElement === firstFocusableElement) { + lastFocusableElement?.focus(); + event.preventDefault(); + } + } else { + if (document.activeElement === lastFocusableElement) { + ( + innerContainer.querySelector(".modal-close") || firstFocusableElement + )?.focus(); + event.preventDefault(); + } + } + } + + @bind + _clearFlash() { + this.flash = null; + } + + @bind + _flash(msg) { + this.flash = msg; + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-modal.hbs b/app/assets/javascripts/discourse/app/components/d-modal.hbs index e91fb19febc..c98c0d928be 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.hbs +++ b/app/assets/javascripts/discourse/app/components/d-modal.hbs @@ -1,94 +1,101 @@ {{! template-lint-disable no-pointer-down-event-binding }} {{! template-lint-disable no-invalid-interactive }} -