Skip to content

Commit

Permalink
DEV: Introduce new component-based DModal API (discourse#21304)
Browse files Browse the repository at this point in the history
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 <DModal component directly in your template.

For more information on the API design, and conversion instructions from the current API, see these Meta topics:

DModal API: https://meta.discourse.org/t/268304
Conversion: https://meta.discourse.org/t/268057
  • Loading branch information
davidtaylorhq authored Jul 3, 2023
1 parent 45c504d commit b3a23bd
Show file tree
Hide file tree
Showing 19 changed files with 917 additions and 239 deletions.
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }}

<div
id={{@id}}
class={{concat-class "modal-body" @class}}
Expand Down
14 changes: 14 additions & 0 deletions app/assets/javascripts/discourse/app/components/d-modal-body.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers)

import Component from "@glimmer/component";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
import { DEBUG } from "@glimmer/env";

const LEGACY_ERROR =
"d-modal-body should only be used inside a legacy controller-based d-modal. https://meta.discourse.org/t/268057";

function pick(object, keys) {
const result = {};
Expand All @@ -23,6 +29,14 @@ export default class DModalBody extends Component {

@action
didInsert(element) {
if (element.closest(".d-modal:not(.d-modal-legacy")) {
// eslint-disable-next-line no-console
console.error(LEGACY_ERROR);
if (DEBUG) {
throw new Error(LEGACY_ERROR);
}
}

this.appEvents.trigger("modal-body:clearFlash");

const fixedParent = element.closest(".d-modal.fixed-modal");
Expand Down
95 changes: 95 additions & 0 deletions app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{{! Remove when legacy modals are dropped (deprecation: discourse.modal-controllers) }}

{{! template-lint-disable no-pointer-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}

<div
class={{concat-class
this.modalClass
this.modalStyle
(if this.hasPanels "has-panels")
(if @hidden "hidden")
"d-modal-legacy"
}}
id={{if (not-eq this.modalStyle "inline-modal") "discourse-modal"}}
data-keyboard="false"
aria-modal="true"
role="dialog"
aria-labelledby={{this.ariaLabelledby}}
...attributes
{{did-insert this.setupListeners}}
{{will-destroy this.cleanupListeners}}
{{on "mousedown" this.handleMouseDown}}
>
<div class="modal-outer-container">
<div class="modal-middle-container">
<div class="modal-inner-container">
<PluginOutlet @name="above-modal-header" @connectorTagName="div" />
<div class="modal-header {{this.headerClass}}">
{{#if this.dismissable}}
<DButton
@icon="times"
@action={{route-action "closeModal" "initiatedByCloseButton"}}
@class="btn-flat modal-close close"
@title="modal.close"
/>
{{/if}}

<div class="modal-title-wrapper">
{{#if this.title}}
<div class="title">
<h3 id="discourse-modal-title">{{this.title}}</h3>

{{#if this.subtitle}}
<p class="subtitle">{{this.subtitle}}</p>
{{/if}}
</div>
{{/if}}

<span id="modal-header-after-title"></span>
</div>

{{#if this.panels}}
<ul class="modal-tabs">
{{#each this.panels as |panel|}}
<ModalTab
@panel={{panel}}
@panelsLength={{this.panels.length}}
@selectedPanel={{@selectedPanel}}
@onSelectPanel={{@onSelectPanel}}
/>
{{/each}}
</ul>
{{/if}}
</div>

<div
id="modal-alert"
role="alert"
class={{if
this.flash
(concat-class
"alert" (concat "alert-" (or this.flash.messageClass "success"))
)
}}
>
{{~this.flash.text~}}
</div>

{{yield}}

{{#each this.errors as |error|}}
<div class="alert alert-error">
<button
type="button"
class="close"
data-dismiss="alert"
aria-label={{i18n "modal.dismiss_error"}}
>×</button>
{{error}}
</div>
{{/each}}
</div>
</div>
</div>
</div>
253 changes: 253 additions & 0 deletions app/assets/javascripts/discourse/app/components/d-modal-legacy.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit b3a23bd

Please sign in to comment.