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", "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
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 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;
}
} }

View File

@ -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();
} }
} }

View File

@ -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';

View File

@ -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.

View File

@ -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"

View File

@ -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;