mirror of
https://github.com/flarum/framework.git
synced 2024-11-25 09:41:49 +08:00
refactor: Modals rewrite to use native HTML Dialog element
This commit is contained in:
parent
ed3ea05c1a
commit
58441b1d1f
|
@ -9,6 +9,7 @@
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"color-thief-browser": "^2.0.2",
|
"color-thief-browser": "^2.0.2",
|
||||||
"dayjs": "^1.10.7",
|
"dayjs": "^1.10.7",
|
||||||
|
"dialog-polyfill": "^0.5.6",
|
||||||
"focus-trap": "^6.7.1",
|
"focus-trap": "^6.7.1",
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"jquery.hotkeys": "^0.1.0",
|
"jquery.hotkeys": "^0.1.0",
|
||||||
|
|
46
js/src/@types/modals/index.d.ts
vendored
Normal file
46
js/src/@types/modals/index.d.ts
vendored
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* Only supported natively in Chrome. In testing in Safari Technology Preview.
|
||||||
|
*
|
||||||
|
* Please register modals with the dialog polyfill before use:
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* dialogPolyfill.registerDialog(dialogElementReference);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Events
|
||||||
|
*
|
||||||
|
* Two events are fired by dialogs:
|
||||||
|
* - `cancel` - Fired when the user instructs the browser that they wish to dismiss the current open dialog.
|
||||||
|
* - `close` - Fired when the dialog is closed.
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement
|
||||||
|
*/
|
||||||
|
interface HTMLDialogElement {
|
||||||
|
/**
|
||||||
|
* Shows the `<dialog>` element as a top-layered element in the document.
|
||||||
|
*/
|
||||||
|
show(): void;
|
||||||
|
/**
|
||||||
|
* Displays the dialog as a modal, over the top of any other dialogs that
|
||||||
|
* might be present. Interaction outside the dialog is blocked.
|
||||||
|
*/
|
||||||
|
showModal(): void;
|
||||||
|
/**
|
||||||
|
* If the `<dialog>` element is currently being shown, dismiss it.
|
||||||
|
*
|
||||||
|
* @param returnValue An optional return value for the dialog to hold. *This is currently unused by Flarum.*
|
||||||
|
*/
|
||||||
|
close(returnValue?: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A return value for the dialog to hold.
|
||||||
|
*
|
||||||
|
* *This is currently unused by Flarum.*
|
||||||
|
*/
|
||||||
|
returnValue: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the dialog is currently open.
|
||||||
|
*/
|
||||||
|
open: boolean;
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import type ModalManagerState from '../states/ModalManagerState';
|
||||||
import type RequestError from '../utils/RequestError';
|
import type RequestError from '../utils/RequestError';
|
||||||
import type ModalManager from './ModalManager';
|
import type ModalManager from './ModalManager';
|
||||||
import fireDebugWarning from '../helpers/fireDebugWarning';
|
import fireDebugWarning from '../helpers/fireDebugWarning';
|
||||||
|
import classList from '../utils/classList';
|
||||||
|
|
||||||
export interface IInternalModalAttrs {
|
export interface IInternalModalAttrs {
|
||||||
state: ModalManagerState;
|
state: ModalManagerState;
|
||||||
|
@ -15,16 +16,63 @@ export interface IInternalModalAttrs {
|
||||||
animateHide: ModalManager['animateHide'];
|
animateHide: ModalManager['animateHide'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IDismissibleOptions {
|
||||||
|
/**
|
||||||
|
* @deprecated Check specific individual attributes instead. Will be removed in Flarum 2.0.
|
||||||
|
*/
|
||||||
|
isDismissible: boolean;
|
||||||
|
viaCloseButton: boolean;
|
||||||
|
viaEscKey: boolean;
|
||||||
|
viaBackdropClick: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
|
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
|
||||||
* should implement the `className`, `title`, and `content` methods.
|
* should implement the `className`, `title`, and `content` methods.
|
||||||
*/
|
*/
|
||||||
export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Component<ModalAttrs> {
|
export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Component<ModalAttrs> {
|
||||||
|
// TODO: [Flarum 2.0] remove `isDismissible` static attribute
|
||||||
/**
|
/**
|
||||||
* Determine whether or not the modal should be dismissible via an 'x' button.
|
* Determine whether or not the modal should be dismissible via an 'x' button.
|
||||||
|
*
|
||||||
|
* @deprecated Use the individual `isDismissibleVia...` attributes instead and remove references to this.
|
||||||
*/
|
*/
|
||||||
static readonly isDismissible: boolean = true;
|
static readonly isDismissible: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can the model be dismissed with a close button (X)?
|
||||||
|
*
|
||||||
|
* If `false`, no close button is shown.
|
||||||
|
*/
|
||||||
|
protected static readonly isDismissibleViaCloseButton: boolean = true;
|
||||||
|
/**
|
||||||
|
* Can the modal be dismissed by pressing the Esc key on a keyboard?
|
||||||
|
*/
|
||||||
|
protected static readonly isDismissibleViaEscKey: boolean = true;
|
||||||
|
/**
|
||||||
|
* Can the modal be dismissed via a click on the backdrop.
|
||||||
|
*/
|
||||||
|
protected static readonly isDismissibleViaBackdropClick: boolean = true;
|
||||||
|
|
||||||
|
static get dismissibleOptions(): IDismissibleOptions {
|
||||||
|
// If someone sets this to `false`, provide the same behaviour as previous versions of Flarum.
|
||||||
|
if (!this.isDismissible) {
|
||||||
|
return {
|
||||||
|
isDismissible: false,
|
||||||
|
viaCloseButton: false,
|
||||||
|
viaEscKey: false,
|
||||||
|
viaBackdropClick: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDismissible: true,
|
||||||
|
viaCloseButton: this.isDismissibleViaCloseButton,
|
||||||
|
viaEscKey: this.isDismissibleViaEscKey,
|
||||||
|
viaBackdropClick: this.isDismissibleViaBackdropClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected loading: boolean = false;
|
protected loading: boolean = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -87,16 +135,16 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'Modal modal-dialog ' + this.className()}>
|
<dialog className={classList('Modal modal-dialog fade', this.className())}>
|
||||||
<div className="Modal-content">
|
<div className="Modal-content">
|
||||||
{(this.constructor as typeof Modal).isDismissible && (
|
{this.dismissibleOptions.viaCloseButton && (
|
||||||
<div className="Modal-close App-backControl">
|
<div className="Modal-close App-backControl">
|
||||||
{Button.component({
|
<Button
|
||||||
icon: 'fas fa-times',
|
icon="fas fa-times"
|
||||||
onclick: () => this.hide(),
|
onclick={() => this.hide()}
|
||||||
className: 'Button Button--icon Button--link',
|
className="Button Button--icon Button--link"
|
||||||
'aria-label': app.translator.trans('core.lib.modal.close'),
|
aria-label={app.translator.trans('core.lib.modal.close')}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -110,7 +158,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||||
{this.content()}
|
{this.content()}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,4 +223,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
|
||||||
this.onready();
|
this.onready();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get dismissibleOptions(): IDismissibleOptions {
|
||||||
|
return (this.constructor as typeof Modal).dismissibleOptions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,12 +22,14 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||||
*/
|
*/
|
||||||
protected modalShown: boolean = false;
|
protected modalShown: boolean = false;
|
||||||
|
|
||||||
|
protected modalClosing: boolean = false;
|
||||||
|
|
||||||
view(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): Mithril.Children {
|
view(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): Mithril.Children {
|
||||||
const modal = this.attrs.state.modal;
|
const modal = this.attrs.state.modal;
|
||||||
const Tag = modal?.componentClass;
|
const Tag = modal?.componentClass;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ModalManager modal fade">
|
<div className="ModalManager modal">
|
||||||
{!!Tag && (
|
{!!Tag && (
|
||||||
<Tag
|
<Tag
|
||||||
key={modal?.key}
|
key={modal?.key}
|
||||||
|
@ -44,11 +46,6 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||||
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
|
||||||
super.oncreate(vnode);
|
super.oncreate(vnode);
|
||||||
|
|
||||||
// Ensure the modal state is notified about a closed modal, even when the
|
|
||||||
// DOM-based Bootstrap JavaScript code triggered the closing of the modal,
|
|
||||||
// e.g. via ESC key or a click on the modal backdrop.
|
|
||||||
this.$().on('hidden.bs.modal', this.attrs.state.close.bind(this.attrs.state));
|
|
||||||
|
|
||||||
this.focusTrap = createFocusTrap(this.element as HTMLElement);
|
this.focusTrap = createFocusTrap(this.element as HTMLElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,35 +62,74 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get dialogElement(): HTMLDialogElement {
|
||||||
|
return this.element.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
}
|
||||||
|
|
||||||
animateShow(readyCallback: () => void): void {
|
animateShow(readyCallback: () => void): void {
|
||||||
if (!this.attrs.state.modal) return;
|
if (!this.attrs.state.modal) return;
|
||||||
|
|
||||||
const dismissible = !!this.attrs.state.modal.componentClass.isDismissible;
|
const dismissibleState = this.attrs.state.modal.componentClass.dismissibleOptions;
|
||||||
|
|
||||||
this.modalShown = true;
|
this.modalShown = true;
|
||||||
|
|
||||||
// If we are opening this modal while another modal is already open,
|
// Register with polyfill
|
||||||
// the shown event will not run, because the modal is already open.
|
dialogPolyfill.registerDialog(this.dialogElement);
|
||||||
// So, we need to manually trigger the readyCallback.
|
|
||||||
if (this.$().hasClass('in')) {
|
|
||||||
readyCallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$()
|
if (!dismissibleState.viaEscKey) this.dialogElement.addEventListener('cancel', this.preventEscPressHandler);
|
||||||
.one('shown.bs.modal', readyCallback)
|
if (dismissibleState.viaBackdropClick) this.dialogElement.addEventListener('click', (e) => this.handleBackdropClick.call(this, e));
|
||||||
// @ts-expect-error: No typings available for Bootstrap modals.
|
|
||||||
.modal({
|
this.dialogElement.addEventListener('transitionend', () => readyCallback(), { once: true });
|
||||||
backdrop: dismissible || 'static',
|
// Ensure the modal state is ALWAYS notified about a closed modal
|
||||||
keyboard: dismissible,
|
this.dialogElement.addEventListener('close', this.attrs.state.close.bind(this.attrs.state));
|
||||||
})
|
|
||||||
.modal('show');
|
// Use close animation instead
|
||||||
|
this.dialogElement.addEventListener('cancel', this.animateCloseHandler.bind(this));
|
||||||
|
|
||||||
|
this.dialogElement.showModal();
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.dialogElement.classList.add('in');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
animateHide(): void {
|
animateHide(): void {
|
||||||
// @ts-expect-error: No typings available for Bootstrap modals.
|
if (this.modalClosing || !this.modalShown) return;
|
||||||
this.$().modal('hide');
|
this.modalClosing = true;
|
||||||
|
|
||||||
this.modalShown = false;
|
this.dialogElement.addEventListener(
|
||||||
|
'transitionend',
|
||||||
|
() => {
|
||||||
|
this.dialogElement.close();
|
||||||
|
this.dialogElement.removeEventListener('cancel', this.preventEscPressHandler);
|
||||||
|
|
||||||
|
this.modalShown = false;
|
||||||
|
this.modalClosing = false;
|
||||||
|
m.redraw();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dialogElement.classList.remove('in');
|
||||||
|
this.dialogElement.classList.add('out');
|
||||||
|
}
|
||||||
|
|
||||||
|
animateCloseHandler(this: this, e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.animateHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
preventEscPressHandler(this: this, e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBackdropClick(this: this, e: MouseEvent) {
|
||||||
|
// If it's a click on the dialog element, the backdrop has been clicked.
|
||||||
|
// If it was a click in the modal, the element would be `div.Modal-content` or some other element.
|
||||||
|
if (e.target !== this.dialogElement) return;
|
||||||
|
|
||||||
|
this.animateHide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,12 @@ import 'expose-loader?exposes=$,jQuery!jquery';
|
||||||
import 'expose-loader?exposes=m!mithril';
|
import 'expose-loader?exposes=m!mithril';
|
||||||
import 'expose-loader?exposes=dayjs!dayjs';
|
import 'expose-loader?exposes=dayjs!dayjs';
|
||||||
|
|
||||||
|
// HTML dialog element polyfill from the Chrome Dev team: https://github.com/GoogleChrome/dialog-polyfill
|
||||||
|
import dialogPolyfill from 'dialog-polyfill';
|
||||||
|
window.dialogPolyfill = dialogPolyfill;
|
||||||
|
|
||||||
import 'bootstrap/js/affix';
|
import 'bootstrap/js/affix';
|
||||||
import 'bootstrap/js/dropdown';
|
import 'bootstrap/js/dropdown';
|
||||||
import 'bootstrap/js/modal';
|
|
||||||
import 'bootstrap/js/tooltip';
|
import 'bootstrap/js/tooltip';
|
||||||
import 'bootstrap/js/transition';
|
import 'bootstrap/js/transition';
|
||||||
import 'jquery.hotkeys/jquery.hotkeys';
|
import 'jquery.hotkeys/jquery.hotkeys';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type Component from '../Component';
|
import type Component from '../Component';
|
||||||
import Modal from '../components/Modal';
|
import Modal, { IDismissibleOptions } from '../components/Modal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ideally, `show` would take a higher-kinded generic, ala:
|
* Ideally, `show` would take a higher-kinded generic, ala:
|
||||||
|
@ -8,7 +8,7 @@ import Modal from '../components/Modal';
|
||||||
* https://github.com/Microsoft/TypeScript/issues/1213
|
* https://github.com/Microsoft/TypeScript/issues/1213
|
||||||
* Therefore, we have to use this ugly, messy workaround.
|
* Therefore, we have to use this ugly, messy workaround.
|
||||||
*/
|
*/
|
||||||
type UnsafeModalClass = ComponentClass<any, Modal> & { isDismissible: boolean; component: typeof Component.component };
|
type UnsafeModalClass = ComponentClass<any, Modal> & { get dismissibleOptions(): IDismissibleOptions; component: typeof Component.component };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class used to manage modal state.
|
* Class used to manage modal state.
|
||||||
|
|
|
@ -1401,6 +1401,7 @@ __metadata:
|
||||||
color-thief-browser: ^2.0.2
|
color-thief-browser: ^2.0.2
|
||||||
cross-env: ^7.0.3
|
cross-env: ^7.0.3
|
||||||
dayjs: ^1.10.7
|
dayjs: ^1.10.7
|
||||||
|
dialog-polyfill: ^0.5.6
|
||||||
expose-loader: ^3.1.0
|
expose-loader: ^3.1.0
|
||||||
flarum-tsconfig: ^1.0.2
|
flarum-tsconfig: ^1.0.2
|
||||||
flarum-webpack-config: ^2.0.0
|
flarum-webpack-config: ^2.0.0
|
||||||
|
@ -2310,6 +2311,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"dialog-polyfill@npm:^0.5.6":
|
||||||
|
version: 0.5.6
|
||||||
|
resolution: "dialog-polyfill@npm:0.5.6"
|
||||||
|
checksum: dde8d4b6a62b63b710e0d0d70b615d92ff08f3cb2b521e1f99b17e8220d9d55d0fdf9fc17fbe77b0ff1be817094e14b60c4c4bacd5456fba427ede48d2d90230
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"duplexer@npm:^0.1.1, duplexer@npm:^0.1.2":
|
"duplexer@npm:^0.1.1, duplexer@npm:^0.1.2":
|
||||||
version: 0.1.2
|
version: 0.1.2
|
||||||
resolution: "duplexer@npm:0.1.2"
|
resolution: "duplexer@npm:0.1.2"
|
||||||
|
|
|
@ -7,54 +7,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal background
|
// Modal background
|
||||||
.modal-backdrop {
|
.ModalManager dialog {
|
||||||
position: fixed;
|
//! These MUST be defined separately
|
||||||
top: 0;
|
//
|
||||||
right: 0;
|
// When browsers do not understand a pseudoselector, the entire block is ignored,
|
||||||
bottom: 0;
|
// meaning no backdrop would be visible at all.
|
||||||
left: 0;
|
&::backdrop {
|
||||||
z-index: var(--zindex-modal-background);
|
// `::backdrop` does not inherit anything from parents, so we need to declare this here.
|
||||||
background-color: var(--overlay-bg);
|
// See: https://stackoverflow.com/a/63322762/11091039
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
&.in {
|
--overlay-bg: @overlay-bg;
|
||||||
opacity: 1;
|
background: var(--overlay-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ .backdrop {
|
||||||
|
background: var(--overlay-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container that the modal scrolls within
|
// Container that the modal scrolls within
|
||||||
.ModalManager {
|
.ModalManager {
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: var(--zindex-modal);
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
|
|
||||||
// When fading in the modal, animate it to slide down
|
// When fading in the modal, animate it to slide down
|
||||||
.Modal {
|
dialog {
|
||||||
transform: scale(0.9);
|
border-width: 0;
|
||||||
transition: transform 0.2s ease-out;
|
padding: 0;
|
||||||
}
|
overflow: visible;
|
||||||
&.in .Modal {
|
border-radius: @border-radius;
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.modal-open .ModalManager {
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shell div to position the modal with bottom padding
|
transform: scale(0.9);
|
||||||
.Modal {
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
||||||
position: relative;
|
|
||||||
width: auto;
|
&.in {
|
||||||
margin: 10px;
|
transform: scale(1);
|
||||||
max-width: 600px;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actual modal
|
// Actual modal
|
||||||
|
@ -173,6 +159,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media @tablet-up {
|
@media @tablet-up {
|
||||||
|
.ModalManager dialog {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0 7px 15px var(--shadow-color);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.Modal {
|
.Modal {
|
||||||
margin: 120px auto;
|
margin: 120px auto;
|
||||||
}
|
}
|
||||||
|
@ -183,10 +174,7 @@
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.Modal-content {
|
.Modal-content {
|
||||||
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: 0 7px 15px var(--shadow-color);
|
|
||||||
}
|
}
|
||||||
.Modal--small {
|
.Modal--small {
|
||||||
max-width: 375px;
|
max-width: 375px;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user