-
-
-
\ No newline at end of file
+ {{#unless @inline}}
+
+ {{/unless}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js
index 3190663397c..74d273caf70 100644
--- a/app/assets/javascripts/discourse/app/components/d-modal.js
+++ b/app/assets/javascripts/discourse/app/components/d-modal.js
@@ -1,121 +1,54 @@
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";
+import { inject as service } from "@ember/service";
+
+export const CLOSE_INITIATED_BY_BUTTON = "initiatedByCloseButton";
+export const CLOSE_INITIATED_BY_ESC = "initiatedByESC";
+export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut";
+export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow";
-@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.handleDocumentKeydown
);
this.wrapperElement = element;
+ this.trapTab();
}
@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
+ this.handleDocumentKeydown
);
}
- 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 dismissable() {
+ if (!this.args.closeModal) {
+ return false;
+ } else if ("dismissable" in this.args) {
+ return this.args.dismissable;
+ } else {
+ return true;
}
}
- get modalClass() {
- return this.modalBodyData.modalClass || this.args.modalClass;
- }
-
- triggerClickOnEnter(e) {
- if (!this.submitOnEnter) {
+ shouldTriggerClickOnEnter(event) {
+ if (this.args.submitOnEnter === false) {
return false;
}
// skip when in a form or a textarea element
if (
- e.target.closest("form") ||
- (document.activeElement && document.activeElement.nodeName === "TEXTAREA")
+ event.target.closest("form") ||
+ document.activeElement?.nodeName === "TEXTAREA"
) {
return false;
}
@@ -124,7 +57,11 @@ export default class DModal extends Component {
}
@action
- handleMouseDown(e) {
+ handleMouseUp(e) {
+ if (e.button !== 0) {
+ return; // Non-default mouse button
+ }
+
if (!this.dismissable) {
return;
}
@@ -133,53 +70,34 @@ export default class DModal extends Component {
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();
+ return this.args.closeModal?.({
+ initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE,
});
- });
+ }
}
- @bind
- _handleModalEvents(event) {
+ @action
+ handleDocumentKeydown(event) {
if (this.args.hidden) {
return;
}
if (event.key === "Escape" && this.dismissable) {
- next(() => this.args.closeModal("initiatedByESC"));
+ this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_ESC });
}
- if (event.key === "Enter" && this.triggerClickOnEnter(event)) {
+ if (event.key === "Enter" && this.shouldTriggerClickOnEnter(event)) {
this.wrapperElement.querySelector(".modal-footer .btn-primary")?.click();
event.preventDefault();
}
if (event.key === "Tab") {
- this._trapTab(event);
+ this.trapTab(event);
}
}
- _trapTab(event) {
+ @action
+ trapTab(event) {
if (this.args.hidden) {
return true;
}
@@ -239,13 +157,8 @@ export default class DModal extends Component {
}
}
- @bind
- _clearFlash() {
- this.flash = null;
- }
-
- @bind
- _flash(msg) {
- this.flash = msg;
+ @action
+ handleCloseButton() {
+ this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_BUTTON });
}
}
diff --git a/app/assets/javascripts/discourse/app/components/modal-container.hbs b/app/assets/javascripts/discourse/app/components/modal-container.hbs
index f822de045ca..d081003cb8f 100644
--- a/app/assets/javascripts/discourse/app/components/modal-container.hbs
+++ b/app/assets/javascripts/discourse/app/components/modal-container.hbs
@@ -1,7 +1,24 @@
-
+
+
+{{#if this.modal.modalBodyComponent}}
+
+{{/if}}
+
+{{! Legacy modals depend on this wrapper being in the DOM at all times. Eventually this will be dropped.
+For now, we mitigate the potential impact on things like tests by removing the `modal` and `d-modal` classes when inactive }}
+
{{outlet "modalBody"}}
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/modal-container.js b/app/assets/javascripts/discourse/app/components/modal-container.js
index a12b79783c8..280cda29cfb 100644
--- a/app/assets/javascripts/discourse/app/components/modal-container.js
+++ b/app/assets/javascripts/discourse/app/components/modal-container.js
@@ -6,7 +6,7 @@ export default class ModalContainer extends Component {
@service modal;
@action
- closeModal(initiatedBy) {
- this.modal.close(initiatedBy);
+ closeModal(data) {
+ this.modal.close(data);
}
}
diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js
index a0d93958b70..69affb7cee0 100644
--- a/app/assets/javascripts/discourse/app/lib/show-modal.js
+++ b/app/assets/javascripts/discourse/app/lib/show-modal.js
@@ -1,3 +1,5 @@
+// Remove when legacy modals are dropped (deprecation: discourse.modal-controllers)
+
import { getOwner } from "discourse-common/lib/get-owner";
/**
@@ -17,6 +19,11 @@ import { getOwner } from "discourse-common/lib/get-owner";
* @returns {Controller} The modal controller instance
*/
export default function showModal(name, opts) {
+ if (typeof name !== "string") {
+ throw new Error(
+ "`discourse/lib/show-modal` can only be used with the legacy controller-based API. To use the new component-based API, inject the modal service and call modal.show(). https://meta.discourse.org/t/268057"
+ );
+ }
opts = opts || {};
let container = getOwner(this);
diff --git a/app/assets/javascripts/discourse/app/services/modal.js b/app/assets/javascripts/discourse/app/services/modal.js
index 532db42aad2..5a10dd5b812 100644
--- a/app/assets/javascripts/discourse/app/services/modal.js
+++ b/app/assets/javascripts/discourse/app/services/modal.js
@@ -1,16 +1,80 @@
import Service, { inject as service } from "@ember/service";
+import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/application";
import I18n from "I18n";
import { dasherize } from "@ember/string";
+import { action } from "@ember/object";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
-import { tracked } from "@glimmer/tracking";
+import { CLOSE_INITIATED_BY_MODAL_SHOW } from "discourse/components/d-modal";
+import deprecated from "discourse-common/lib/deprecated";
+
+const LEGACY_OPTS = new Set([
+ "admin",
+ "templateName",
+ "title",
+ "titleTranslated",
+ "modalClass",
+ "titleAriaElementId",
+ "panels",
+]);
@disableImplicitInjections
-export default class ModalService extends Service {
+class ModalService extends Service {
+ @tracked modalBodyComponent;
+ @tracked opts = {};
+ @tracked containerElement;
+ #resolveShowPromise;
+
+ @action
+ setContainerElement(element) {
+ this.containerElement = element;
+ }
+
+ /**
+ * Render a modal
+ *
+ * @param {Component} modal - a reference to the component class for the modal
+ * @param {Object} [options] - options
+ * @param {string} [options.model] - An object which will be passed as the `@model` argument on the component
+ *
+ * @returns {Promise} A promise that resolves when the modal is closed, with any data passed to closeModal
+ */
+ show(modal, opts) {
+ this.close({ initiatedBy: CLOSE_INITIATED_BY_MODAL_SHOW });
+
+ const promise = new Promise((resolve) => {
+ this.#resolveShowPromise = resolve;
+ });
+
+ this.opts = opts || {};
+ this.modalBodyComponent = modal;
+
+ const unsupportedOpts = Object.keys(opts).filter((key) =>
+ LEGACY_OPTS.has(key)
+ );
+ if (unsupportedOpts.length > 0) {
+ throw new Error(
+ `${unsupportedOpts.join(
+ ", "
+ )} are not supported in the component-based modal API. See https://meta.discourse.org/t/268057`
+ );
+ }
+
+ return promise;
+ }
+
+ close(data) {
+ this.#resolveShowPromise?.(data);
+ this.#resolveShowPromise = this.modalBodyComponent = null;
+ this.opts = {};
+ }
+}
+
+// Remove all logic below when legacy modals are dropped (deprecation: discourse.modal-controllers)
+export default class ModalServiceWithLegacySupport extends ModalService {
@service appEvents;
@tracked name;
- @tracked opts = {};
@tracked selectedPanel;
@tracked hidden = true;
@@ -35,7 +99,7 @@ export default class ModalService extends Service {
}
get modalClass() {
- if (!this.#isRendered) {
+ if (!this.isLegacy) {
return null;
}
@@ -50,7 +114,22 @@ export default class ModalService extends Service {
this.modalClassOverride = value;
}
- show(name, opts = {}) {
+ show(modal, opts = {}) {
+ if (typeof modal !== "string") {
+ return super.show(modal, opts);
+ }
+
+ deprecated(
+ "Defining modals using a controller is deprecated. Use the component-based API instead.",
+ {
+ id: "discourse.modal-controllers",
+ since: "3.1",
+ dropFrom: "3.2",
+ url: "https://meta.discourse.org/t/268057",
+ }
+ );
+
+ const name = modal;
const container = getOwner(this);
const route = container.lookup("route:application");
@@ -96,10 +175,14 @@ export default class ModalService extends Service {
}
controller.set("flashMessage", null);
- return controller;
+ return (this.activeController = controller);
}
close(initiatedBy) {
+ if (!this.isLegacy) {
+ super.close(...arguments);
+ }
+
const controllerName = this.name;
const controller = controllerName
? getOwner(this).lookup(`controller:${controllerName}`)
@@ -137,18 +220,26 @@ export default class ModalService extends Service {
this.onSelectPanel =
null;
- this.opts = {};
+ super.close();
}
hide() {
- $(".d-modal.fixed-modal").modal("hide");
+ if (this.isLegacy) {
+ $(".d-modal.fixed-modal").modal("hide");
+ } else {
+ throw "hide/reopen are not supported for component-based modals";
+ }
}
reopen() {
- $(".d-modal.fixed-modal").modal("show");
+ if (this.isLegacy) {
+ $(".d-modal.fixed-modal").modal("show");
+ } else {
+ throw "hide/reopen are not supported for component-based modals";
+ }
}
- get #isRendered() {
- return !!this.name;
+ get isLegacy() {
+ return this.name && !this.modalBodyComponent;
}
}
diff --git a/app/assets/javascripts/discourse/config/deprecation-workflow.js b/app/assets/javascripts/discourse/config/deprecation-workflow.js
index 7b1f40b93a5..bf06b2d4990 100644
--- a/app/assets/javascripts/discourse/config/deprecation-workflow.js
+++ b/app/assets/javascripts/discourse/config/deprecation-workflow.js
@@ -8,5 +8,6 @@ globalThis.deprecationWorkflow.config = {
{ handler: "silence", matchId: "route-disconnect-outlet" },
{ handler: "silence", matchId: "this-property-fallback" },
{ handler: "silence", matchId: "discourse.select-kit" },
+ { handler: "silence", matchId: "discourse.modal-controllers" },
],
};
diff --git a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
index 29da79004cc..18b8300d213 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js
@@ -38,7 +38,7 @@ acceptance("Do not disturb", function (needs) {
await click(tiles[0]);
- assert.ok(query(".d-modal.hidden"), "modal is hidden");
+ assert.dom(".d-modal").doesNotExist("modal is hidden");
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),
@@ -68,10 +68,9 @@ acceptance("Do not disturb", function (needs) {
"Enter"
);
- assert.ok(
- query(".d-modal.hidden"),
- "DND modal is hidden after making a choice"
- );
+ assert
+ .dom(".d-modal")
+ .doesNotExist("DND modal is hidden after making a choice");
assert.ok(
exists(".header-dropdown-toggle .do-not-disturb-background .d-icon-moon"),
diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js
similarity index 99%
rename from app/assets/javascripts/discourse/tests/acceptance/modal-test.js
rename to app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js
index b27e6ab25d2..fcaa35bba9a 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/modal-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js
@@ -12,7 +12,7 @@ import showModal from "discourse/lib/show-modal";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
import { getOwner } from "discourse-common/lib/get-owner";
-acceptance("Modal", function (needs) {
+acceptance("Legacy Modal", function (needs) {
let _translations;
needs.hooks.beforeEach(() => {
_translations = I18n.translations;
diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal-service-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal-service-test.js
new file mode 100644
index 00000000000..2c3c48c3f25
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/modal-service-test.js
@@ -0,0 +1,113 @@
+import { acceptance } from "discourse/tests/helpers/qunit-helpers";
+import { click, settled, triggerKeyEvent, visit } from "@ember/test-helpers";
+import { test } from "qunit";
+import { hbs } from "ember-cli-htmlbars";
+import { getOwner } from "@ember/application";
+import Component from "@glimmer/component";
+import { setComponentTemplate } from "@glimmer/manager";
+import {
+ CLOSE_INITIATED_BY_BUTTON,
+ CLOSE_INITIATED_BY_CLICK_OUTSIDE,
+ CLOSE_INITIATED_BY_ESC,
+ CLOSE_INITIATED_BY_MODAL_SHOW,
+} from "discourse/components/d-modal";
+import { action } from "@ember/object";
+
+class MyModalClass extends Component {
+ @action
+ closeWithCustomData() {
+ this.args.closeModal({ hello: "world" });
+ }
+}
+setComponentTemplate(
+ hbs`
+
+ Modal content is {{@model.text}}
+
+
+ `,
+ MyModalClass
+);
+
+acceptance("Modal service: component-based API", function () {
+ test("displays correctly", async function (assert) {
+ await visit("/");
+
+ assert.dom(".d-modal").doesNotExist("there is no modal at first");
+
+ const modalService = getOwner(this).lookup("service:modal");
+
+ let promise = modalService.show(MyModalClass, {
+ model: { text: "working" },
+ });
+ await settled();
+ assert.dom(".d-modal").exists("modal should appear");
+
+ assert.dom(".d-modal .title h3").hasText("Hello World");
+ assert.dom(".d-modal .modal-body").hasText("Modal content is working");
+
+ await click(".modal-outer-container");
+ assert.dom(".d-modal").doesNotExist("disappears on click outside");
+ assert.deepEqual(
+ await promise,
+ { initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE },
+ "promise resolves with correct initiator"
+ );
+
+ promise = modalService.show(MyModalClass, { model: { text: "working" } });
+ await settled();
+ assert.dom(".d-modal").exists("modal reappears");
+
+ await triggerKeyEvent("#main-outlet", "keydown", "Escape");
+ assert.dom(".d-modal").doesNotExist("disappears on escape");
+ assert.deepEqual(
+ await promise,
+ { initiatedBy: CLOSE_INITIATED_BY_ESC },
+ "promise resolves with correct initiator"
+ );
+
+ promise = modalService.show(MyModalClass, { model: { text: "working" } });
+ await settled();
+ assert.dom(".d-modal").exists("modal reappears");
+
+ await click(".d-modal .modal-close");
+ assert.dom(".d-modal").doesNotExist("disappears when close button clicked");
+ assert.deepEqual(
+ await promise,
+ { initiatedBy: CLOSE_INITIATED_BY_BUTTON },
+ "promise resolves with correct initiator"
+ );
+
+ promise = modalService.show(MyModalClass, { model: { text: "working" } });
+ await settled();
+ assert.dom(".d-modal").exists("modal reappears");
+
+ await click(".d-modal .modal-close");
+ assert.dom(".d-modal").doesNotExist("disappears when close button clicked");
+ assert.deepEqual(
+ await promise,
+ { initiatedBy: CLOSE_INITIATED_BY_BUTTON },
+ "promise resolves with correct initiator"
+ );
+
+ promise = modalService.show(MyModalClass, { model: { text: "first" } });
+ await settled();
+ assert.dom(".d-modal").exists("modal reappears");
+
+ modalService.show(MyModalClass, { model: { text: "second" } });
+ await settled();
+ assert
+ .dom(".d-modal .modal-body")
+ .hasText("Modal content is second", "new modal replaces old");
+ assert.deepEqual(
+ await promise,
+ { initiatedBy: CLOSE_INITIATED_BY_MODAL_SHOW },
+ "first modal promise resolves with correct initiator"
+ );
+ });
+
+ // (See also, `tests/integration/component/d-modal-test.js`)
+});
diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js
new file mode 100644
index 00000000000..fa41c66eff9
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/d-modal-test.js
@@ -0,0 +1,75 @@
+import { module, test } from "qunit";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import { click, render, settled } from "@ember/test-helpers";
+import { hbs } from "ember-cli-htmlbars";
+
+module("Integration | Component | d-modal", function (hooks) {
+ setupRenderingTest(hooks);
+
+ test("title and subtitle", async function (assert) {
+ await render(
+ hbs`
`
+ );
+ assert.dom(".d-modal .title h3").hasText("Modal Title");
+ assert.dom(".d-modal .subtitle").hasText("Modal Subtitle");
+ });
+
+ test("named blocks", async function (assert) {
+ await render(
+ hbs`
+
+ <:aboveHeader>aboveHeaderContent
+ <:headerAboveTitle>headerAboveTitleContent
+ <:headerBelowTitle>headerBelowTitleContent
+ <:belowHeader>belowHeaderContent
+ <:body>bodyContent
+ <:footer>footerContent
+ <:belowFooter>belowFooterContent
+
+ `
+ );
+
+ assert.dom(".d-modal").includesText("aboveHeaderContent");
+ assert.dom(".d-modal").includesText("headerAboveTitleContent");
+ assert.dom(".d-modal").includesText("headerBelowTitleContent");
+ assert.dom(".d-modal").includesText("belowHeaderContent");
+ assert.dom(".d-modal").includesText("bodyContent");
+ assert.dom(".d-modal").includesText("footerContent");
+ assert.dom(".d-modal").includesText("belowFooterContent");
+ });
+
+ test("flash", async function (assert) {
+ await render(
+ hbs`
`
+ );
+ assert.dom(".d-modal .alert.alert-error").hasText("Some message");
+ });
+
+ test("dismissable", async function (assert) {
+ let closeModalCalled = false;
+ this.closeModal = () => (closeModalCalled = true);
+ this.set("dismissable", false);
+
+ await render(
+ hbs`
`
+ );
+
+ assert
+ .dom(".d-modal .modal-close")
+ .doesNotExist("close button is not shown when dismissable=false");
+
+ this.set("dismissable", true);
+ await settled();
+ assert
+ .dom(".d-modal .modal-close")
+ .exists("close button is visible when dismissable=true");
+
+ await click(".d-modal .modal-close");
+ assert.true(
+ closeModalCalled,
+ "closeModal is called when close button clicked"
+ );
+
+ closeModalCalled = false;
+ });
+});
diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs
index 4a89642727f..a0cd9b54630 100644
--- a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs
+++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.hbs
@@ -1,15 +1,57 @@
-
">
-
-
- {{html-safe @dummy.lorem}}
-
+{{! template-lint-disable no-potential-path-strings}}
-
-
+">
+
+
+ <:body>
+ {{this.body}}
+
+
+ <:footer>
+ {{i18n "styleguide.sections.modal.footer"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ">
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.js b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.js
new file mode 100644
index 00000000000..8f719f8a6bd
--- /dev/null
+++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/organisms/modal.js
@@ -0,0 +1,39 @@
+import { action } from "@ember/object";
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import I18n from "I18n";
+
+export default class extends Component {
+ @tracked inline = true;
+ @tracked dismissable = true;
+ @tracked title = I18n.t("styleguide.sections.modal.header");
+ @tracked body = this.args.dummy.shortLorem;
+ @tracked subtitle = "";
+ @tracked flash = "";
+ @tracked flashType = "success";
+
+ flashTypes = ["success", "info", "warning", "error"];
+
+ @action
+ toggleInline() {
+ this.inline = !this.inline;
+ if (!this.inline) {
+ // Make sure there is a way to dismiss the modal
+ this.dismissable = true;
+ }
+ }
+
+ @action
+ toggleDismissable() {
+ this.dismissable = !this.dismissable;
+ if (!this.dismissable) {
+ // Make sure there is a way to dismiss the modal
+ this.inline = true;
+ }
+ }
+
+ @action
+ toggleShowFooter() {
+ this.showFooter = !this.showFooter;
+ }
+}
diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js
index f1f2260c554..901cd1f878d 100644
--- a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js
+++ b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js
@@ -262,6 +262,8 @@ export function createData(store) {
}),
lorem: cooked,
+ shortLorem:
+ "Lorem ipsum dolor sit amet, et nec quis viderer prompta, ex omnium ponderum insolens eos, sed discere invenire principes in. Fuisset constituto per ad. Est no scripta propriae facilisis, viderer impedit deserunt in mel. Quot debet facilisis ne vix, nam in detracto tacimates. At quidam petentium vulputate pro. Alia iudico repudiandae ad vel, erat omnis epicuri eos id. Et illum dolor graeci vel, quo feugiat consulatu ei.",
topicTimerUpdateDate: "2017-10-18 18:00",