forked from discourse/discourse
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
DEV: Introduce new component-based DModal API (discourse#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 <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
1 parent
45c504d
commit b3a23bd
Showing
19 changed files
with
917 additions
and
239 deletions.
There are no files selected for viewing
9 changes: 9 additions & 0 deletions
9
app/assets/javascripts/discourse/app/components/conditional-in-element.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
2 changes: 2 additions & 0 deletions
2
app/assets/javascripts/discourse/app/components/d-modal-body.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
app/assets/javascripts/discourse/app/components/d-modal-legacy.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
253
app/assets/javascripts/discourse/app/components/d-modal-legacy.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.