DEV: Convert d-modal and d-modal-body to glimmer components

This commit is contained in:
David Taylor 2023-05-12 18:42:43 +01:00
parent 11e7e949b7
commit 771c4de7f1
4 changed files with 199 additions and 158 deletions

View File

@ -0,0 +1,10 @@
<div
id={{@id}}
class={{concat-class "modal-body" @class}}
tabindex="-1"
{{did-insert this.didInsert}}
{{will-destroy this.willDestroy}}
...attributes
>
{{yield}}
</div>

View File

@ -1,54 +1,63 @@
import { attributeBindings, classNames } from "@ember-decorators/component";
import Component from "@ember/component";
import Component from "@glimmer/component";
import { scheduleOnce } from "@ember/runloop";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { inject as service } from "@ember/service";
@classNames("modal-body")
@attributeBindings("tabindex")
function pick(object, keys) {
const result = {};
for (const key of keys) {
if (key in object) {
result[key] = object[key];
}
}
return result;
}
@disableImplicitInjections
export default class DModalBody extends Component {
fixed = false;
submitOnEnter = true;
dismissable = true;
tabindex = -1;
@service appEvents;
didInsertElement() {
super.didInsertElement(...arguments);
@tracked fixed = false;
@action
didInsert(element) {
this._modalAlertElement = document.getElementById("modal-alert");
if (this._modalAlertElement) {
this._clearFlash();
}
let fixedParent = this.element.closest(".d-modal.fixed-modal");
const fixedParent = element.closest(".d-modal.fixed-modal");
if (fixedParent) {
this.set("fixed", true);
this.fixed = true;
$(fixedParent).modal("show");
}
scheduleOnce("afterRender", this, this._afterFirstRender);
this.appEvents.on("modal-body:flash", this, "_flash");
this.appEvents.on("modal-body:clearFlash", this, "_clearFlash");
scheduleOnce("afterRender", () => this._afterFirstRender(element));
}
willDestroyElement() {
super.willDestroyElement(...arguments);
@action
willDestroy() {
this.appEvents.off("modal-body:flash", this, "_flash");
this.appEvents.off("modal-body:clearFlash", this, "_clearFlash");
this.appEvents.trigger("modal:body-dismissed");
}
_afterFirstRender() {
const maxHeight = this.maxHeight;
_afterFirstRender(element) {
const maxHeight = this.args.maxHeight;
if (maxHeight) {
const maxHeightFloat = parseFloat(maxHeight) / 100.0;
if (maxHeightFloat > 0) {
const viewPortHeight = $(window).height();
this.element.style.maxHeight =
element.style.maxHeight =
Math.floor(maxHeightFloat * viewPortHeight) + "px";
}
}
this.appEvents.trigger(
"modal:body-shown",
this.getProperties(
pick(this.args, [
"title",
"rawTitle",
"fixed",
@ -56,8 +65,8 @@ export default class DModalBody extends Component {
"rawSubtitle",
"submitOnEnter",
"dismissable",
"headerClass"
)
"headerClass",
])
);
}

View File

@ -1,56 +1,78 @@
<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}}
{{! template-lint-disable no-down-event-binding }}
{{! template-lint-disable no-invalid-interactive }}
{{#if this.title}}
<div class="title">
<h3 id="discourse-modal-title">{{this.title}}</h3>
<div
class={{concat-class
"modal"
"d-modal"
this.modalClass
this.modalStyle
(if this.hasPanels "has-panels")
}}
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}}
{{#if this.subtitle}}
<p class="subtitle">{{this.subtitle}}</p>
{{/if}}
</div>
{{/if}}
{{#if this.title}}
<div class="title">
<h3 id="discourse-modal-title">{{this.title}}</h3>
{{#if this.panels}}
<ul class="modal-tabs">
{{#each this.panels as |panel|}}
<ModalTab
@panel={{panel}}
@panelsLength={{this.panels.length}}
@selectedPanel={{this.selectedPanel}}
@onSelectPanel={{this.onSelectPanel}}
/>
{{/each}}
</ul>
{{/if}}
</div>
{{#if this.subtitle}}
<p class="subtitle">{{this.subtitle}}</p>
{{/if}}
</div>
{{/if}}
<div id="modal-alert" role="alert"></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}}
{{#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>
{{/each}}
<div id="modal-alert" role="alert"></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>

View File

@ -1,80 +1,102 @@
import {
attributeBindings,
classNameBindings,
} from "@ember-decorators/component";
import Component from "@ember/component";
import Component from "@glimmer/component";
import I18n from "I18n";
import { next, schedule } from "@ember/runloop";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
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";
@classNameBindings(
":modal",
":d-modal",
"modalClass",
"modalStyle",
"hasPanels"
)
@attributeBindings(
"dataKeyboard:data-keyboard",
"ariaModal:aria-modal",
"role",
"ariaLabelledby:aria-labelledby"
)
@disableImplicitInjections
export default class DModal extends Component {
submitOnEnter = true;
dismissable = true;
title = null;
titleAriaElementId = null;
subtitle = null;
role = "dialog";
headerClass = null;
@service appEvents;
// // We handle ESC ourselves
dataKeyboard = "false";
// // Inform screen readers of the modal
ariaModal = "true";
@tracked wrapperElement;
@tracked modalBodyData = {};
init() {
super.init(...arguments);
// If we need to render a second modal for any reason, we can't
// use `elementId`
if (this.modalStyle !== "inline-modal") {
this.set("elementId", "discourse-modal");
this.set("modalStyle", "fixed-modal");
get modalStyle() {
if (this.args.modalStyle === "inline-modal") {
return "inline-modal";
} else {
return "fixed-modal";
}
}
didInsertElement() {
super.didInsertElement(...arguments);
get submitOnEnter() {
if ("submitOnEnter" in this.modalBodyData) {
return this.modalBodyData.submitOnEnter;
} else {
return true;
}
}
this.appEvents.on("modal:body-shown", this, "_modalBodyShown");
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);
document.documentElement.addEventListener(
"keydown",
this._handleModalEvents
);
this.wrapperElement = element;
}
willDestroyElement() {
super.willDestroyElement(...arguments);
this.appEvents.off("modal:body-shown", this, "_modalBodyShown");
@action
cleanupListeners() {
this.appEvents.off("modal:body-shown", this._modalBodyShown);
document.documentElement.removeEventListener(
"keydown",
this._handleModalEvents
);
}
@discourseComputed("title", "titleAriaElementId")
ariaLabelledby(title, titleAriaElementId) {
if (titleAriaElementId) {
return titleAriaElementId;
}
if (title) {
get ariaLabelledby() {
if (this.args.titleAriaElementId) {
return this.args.titleAriaElementId;
} else if (this.args.title) {
return "discourse-modal-title";
}
}
return;
get modalClass() {
return this.modalBodyData.modalClass || this.args.modalClass;
}
triggerClickOnEnter(e) {
@ -93,7 +115,8 @@ export default class DModal extends Component {
return true;
}
mouseDown(e) {
@action
handleMouseDown(e) {
if (!this.dismissable) {
return;
}
@ -105,46 +128,21 @@ export default class DModal extends Component {
// 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.attrs.closeModal?.("initiatedByClickOut");
return this.args.closeModal?.("initiatedByClickOut");
}
}
@bind
_modalBodyShown(data) {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (data.fixed) {
this.element.classList.remove("hidden");
this.wrapperElement.classList.remove("hidden");
}
if (data.title) {
this.set("title", I18n.t(data.title));
} else if (data.rawTitle) {
this.set("title", data.rawTitle);
}
if (data.subtitle) {
this.set("subtitle", I18n.t(data.subtitle));
} else if (data.rawSubtitle) {
this.set("subtitle", data.rawSubtitle);
} else {
// if no subtitle provided, makes sure the previous subtitle
// of another modal is not used
this.set("subtitle", null);
}
if ("submitOnEnter" in data) {
this.set("submitOnEnter", data.submitOnEnter);
}
if ("dismissable" in data) {
this.set("dismissable", data.dismissable);
} else {
this.set("dismissable", true);
}
this.set("headerClass", data.headerClass || null);
this.modalBodyData = data;
schedule("afterRender", () => {
this._trapTab();
@ -153,16 +151,16 @@ export default class DModal extends Component {
@bind
_handleModalEvents(event) {
if (this.element.classList.contains("hidden")) {
if (this.wrapperElement.classList.contains("hidden")) {
return;
}
if (event.key === "Escape" && this.dismissable) {
next(() => this.attrs.closeModal("initiatedByESC"));
next(() => this.args.closeModal("initiatedByESC"));
}
if (event.key === "Enter" && this.triggerClickOnEnter(event)) {
this.element.querySelector(".modal-footer .btn-primary")?.click();
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
event.preventDefault();
}
@ -172,11 +170,13 @@ export default class DModal extends Component {
}
_trapTab(event) {
if (this.element.classList.contains("hidden")) {
if (this.wrapperElement.classList.contains("hidden")) {
return true;
}
const innerContainer = this.element.querySelector(".modal-inner-container");
const innerContainer = this.wrapperElement.querySelector(
".modal-inner-container"
);
if (!innerContainer) {
return;
}