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",
|
||||
"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
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 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user