refactor: Modals rewrite to use native HTML Dialog element

This commit is contained in:
David Wheatley 2022-01-01 16:38:46 +01:00
parent ed3ea05c1a
commit 58441b1d1f
No known key found for this signature in database
GPG Key ID: DCC0FCE349280DFF
8 changed files with 214 additions and 80 deletions

View File

@ -9,6 +9,7 @@
"clsx": "^1.1.1",
"color-thief-browser": "^2.0.2",
"dayjs": "^1.10.7",
"dialog-polyfill": "^0.5.6",
"focus-trap": "^6.7.1",
"jquery": "^3.6.0",
"jquery.hotkeys": "^0.1.0",

46
js/src/@types/modals/index.d.ts vendored Normal file
View 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;
}

View File

@ -8,6 +8,7 @@ import type ModalManagerState from '../states/ModalManagerState';
import type RequestError from '../utils/RequestError';
import type ModalManager from './ModalManager';
import fireDebugWarning from '../helpers/fireDebugWarning';
import classList from '../utils/classList';
export interface IInternalModalAttrs {
state: ModalManagerState;
@ -15,16 +16,63 @@ export interface IInternalModalAttrs {
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
* should implement the `className`, `title`, and `content` methods.
*/
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.
*
* @deprecated Use the individual `isDismissibleVia...` attributes instead and remove references to this.
*/
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;
/**
@ -87,16 +135,16 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
}
return (
<div className={'Modal modal-dialog ' + this.className()}>
<dialog className={classList('Modal modal-dialog fade', this.className())}>
<div className="Modal-content">
{(this.constructor as typeof Modal).isDismissible && (
{this.dismissibleOptions.viaCloseButton && (
<div className="Modal-close App-backControl">
{Button.component({
icon: 'fas fa-times',
onclick: () => this.hide(),
className: 'Button Button--icon Button--link',
'aria-label': app.translator.trans('core.lib.modal.close'),
})}
<Button
icon="fas fa-times"
onclick={() => this.hide()}
className="Button Button--icon Button--link"
aria-label={app.translator.trans('core.lib.modal.close')}
/>
</div>
)}
@ -110,7 +158,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
{this.content()}
</form>
</div>
</div>
</dialog>
);
}
@ -175,4 +223,8 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
this.onready();
}
}
private get dismissibleOptions(): IDismissibleOptions {
return (this.constructor as typeof Modal).dismissibleOptions;
}
}

View File

@ -22,12 +22,14 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
*/
protected modalShown: boolean = false;
protected modalClosing: boolean = false;
view(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): Mithril.Children {
const modal = this.attrs.state.modal;
const Tag = modal?.componentClass;
return (
<div className="ModalManager modal fade">
<div className="ModalManager modal">
{!!Tag && (
<Tag
key={modal?.key}
@ -44,11 +46,6 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
oncreate(vnode: Mithril.VnodeDOM<IModalManagerAttrs, this>): void {
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);
}
@ -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 {
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;
// If we are opening this modal while another modal is already open,
// the shown event will not run, because the modal is already open.
// So, we need to manually trigger the readyCallback.
if (this.$().hasClass('in')) {
readyCallback();
return;
}
// Register with polyfill
dialogPolyfill.registerDialog(this.dialogElement);
this.$()
.one('shown.bs.modal', readyCallback)
// @ts-expect-error: No typings available for Bootstrap modals.
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
})
.modal('show');
if (!dismissibleState.viaEscKey) this.dialogElement.addEventListener('cancel', this.preventEscPressHandler);
if (dismissibleState.viaBackdropClick) this.dialogElement.addEventListener('click', (e) => this.handleBackdropClick.call(this, e));
this.dialogElement.addEventListener('transitionend', () => readyCallback(), { once: true });
// Ensure the modal state is ALWAYS notified about a closed modal
this.dialogElement.addEventListener('close', this.attrs.state.close.bind(this.attrs.state));
// 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 {
// @ts-expect-error: No typings available for Bootstrap modals.
this.$().modal('hide');
if (this.modalClosing || !this.modalShown) return;
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();
}
}

View File

@ -3,9 +3,12 @@ import 'expose-loader?exposes=$,jQuery!jquery';
import 'expose-loader?exposes=m!mithril';
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/dropdown';
import 'bootstrap/js/modal';
import 'bootstrap/js/tooltip';
import 'bootstrap/js/transition';
import 'jquery.hotkeys/jquery.hotkeys';

View File

@ -1,5 +1,5 @@
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:
@ -8,7 +8,7 @@ import Modal from '../components/Modal';
* https://github.com/Microsoft/TypeScript/issues/1213
* 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.

View File

@ -1401,6 +1401,7 @@ __metadata:
color-thief-browser: ^2.0.2
cross-env: ^7.0.3
dayjs: ^1.10.7
dialog-polyfill: ^0.5.6
expose-loader: ^3.1.0
flarum-tsconfig: ^1.0.2
flarum-webpack-config: ^2.0.0
@ -2310,6 +2311,13 @@ __metadata:
languageName: node
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":
version: 0.1.2
resolution: "duplexer@npm:0.1.2"

View File

@ -7,54 +7,40 @@
}
// Modal background
.modal-backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: var(--zindex-modal-background);
background-color: var(--overlay-bg);
opacity: 0;
transition: opacity 0.2s;
.ModalManager dialog {
//! These MUST be defined separately
//
// When browsers do not understand a pseudoselector, the entire block is ignored,
// meaning no backdrop would be visible at all.
&::backdrop {
// `::backdrop` does not inherit anything from parents, so we need to declare this here.
// See: https://stackoverflow.com/a/63322762/11091039
&.in {
opacity: 1;
--overlay-bg: @overlay-bg;
background: var(--overlay-bg);
}
+ .backdrop {
background: var(--overlay-bg);
}
}
// Container that the modal scrolls within
.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
.Modal {
transform: scale(0.9);
transition: transform 0.2s ease-out;
}
&.in .Modal {
transform: scale(1);
}
}
.modal-open .ModalManager {
overflow-x: hidden;
overflow-y: auto;
}
dialog {
border-width: 0;
padding: 0;
overflow: visible;
border-radius: @border-radius;
// Shell div to position the modal with bottom padding
.Modal {
position: relative;
width: auto;
margin: 10px;
max-width: 600px;
transform: scale(0.9);
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
&.in {
transform: scale(1);
}
}
}
// Actual modal
@ -173,6 +159,11 @@
}
@media @tablet-up {
.ModalManager dialog {
border-radius: var(--border-radius);
box-shadow: 0 7px 15px var(--shadow-color);
width: 100%;
}
.Modal {
margin: 120px auto;
}
@ -183,10 +174,7 @@
z-index: 1;
}
.Modal-content {
border: 0;
border-radius: var(--border-radius);
box-shadow: 0 7px 15px var(--shadow-color);
}
.Modal--small {
max-width: 375px;