Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Modal rewrite to use native HTML Dialog element + polyfill #3246

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions js/src/@types/modals/index.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
70 changes: 61 additions & 9 deletions js/src/common/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,71 @@ 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;
animateShow: ModalManager['animateShow'];
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;

/**
Expand Down Expand Up @@ -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>
)}

Expand All @@ -110,7 +158,7 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
{this.content()}
</form>
</div>
</div>
</dialog>
);
}

Expand Down Expand Up @@ -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;
}
}
105 changes: 81 additions & 24 deletions js/src/common/components/ModalManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
*/
protected modalShown: boolean = false;

protected modalClosing: boolean = false;

protected clickStartedOnBackdrop: 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}
Expand All @@ -44,11 +48,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);
}

Expand All @@ -65,35 +64,93 @@ 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);

if (!dismissibleState.viaEscKey) this.dialogElement.addEventListener('cancel', this.preventEscPressHandler);
if (dismissibleState.viaBackdropClick) {
this.dialogElement.addEventListener('mousedown', (e) => this.handleBackdropMouseDown.call(this, e));
this.dialogElement.addEventListener('click', (e) => this.handleBackdropClick.call(this, e));
}

this.$()
.one('shown.bs.modal', readyCallback)
// @ts-expect-error: No typings available for Bootstrap modals.
.modal({
backdrop: dismissible || 'static',
keyboard: dismissible,
})
.modal('show');
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.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');
}

protected animateCloseHandler(this: this, e: Event) {
e.preventDefault();

this.animateHide();
}

protected preventEscPressHandler(this: this, e: Event) {
e.preventDefault();
}

protected handleBackdropMouseDown(this: this, e: MouseEvent) {
// If it's a mousedown on the dialog element, the backdrop has been clicked.
// If it was a mousedown in the modal, the element would be `div.Modal-content` or some other element.
if (e.target !== this.dialogElement) return;

this.clickStartedOnBackdrop = true;
window.addEventListener(
'mouseup',
() => {
if (e.target !== this.dialogElement) this.clickStartedOnBackdrop = false;
},
{ once: true }
);
}

protected 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 || !this.clickStartedOnBackdrop) return;

this.modalShown = false;
this.clickStartedOnBackdrop = false;
this.animateHide();
}
}
5 changes: 4 additions & 1 deletion js/src/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions js/src/common/states/ModalManagerState.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions js/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading