web: manage stacked modals with a stack (#9193)
* web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach (<anonymous>) at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: manage stacked modals with a stack "Events flow up. Instructions flow down." This commit creates a top-level listening controller associated with the main Interface that listens for ModalShow events and registers the modal with a stack. When it receives a corresponding KeyUp:Escape, it closes the topmost modal and removes all references to that modal from the stack. When it receives a ModalHide event, it removes all references to the target modal and removes all references to that modal from the stack. This commit includes a few new techniques. First, thanks to Justin Fagnani and the Shoelace team, this commit includes an alternative technique for declaring custom events by leveraging the GlobalEventHandlers type. This actually works better: the event is explicit, easy to understand, and the typescript language server actually gets them to correspond correctly; if you listen for a specific custom event, the handler had better be of the right type to receive that specific event! Second, this introduces the first custom decorator, @bound(), which eliminates the need to say `this.eventHandler = this.eventHandler.bind(this)` from event handling methods that will have to be passed outside the `this` context of an HTMLElement. After conducting several experiments to see if I understood the PropertyDescriptor protocol correctly, I conclud that this is a safe technique for wiring up `removeEventListener()` handlers. * Prettier had opinions. * web: manage stacked modals with a stack By reviewer request, the `.closeModal()` protocol has been updated so that if the method returns `false` (explicitly; `undefined` is not `false`!), the `.closeModal()` protocol is aborted, the modal remains at the top of the stack, and cleanup is not initiated. Modal forms can now have an "are you sure?" pass if the user triggers a close without saving the form. Figuring out how to close *two* modals if the user *is* sure, and making the Form modal return `true` when the user *is* sure, are left for a future exercise. :-) * web: fix stack handling bug for `Escape`, and make Lint happier about loops
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
|
||||
|
||||
import { state } from "lit/decorators.js";
|
||||
@ -22,6 +23,7 @@ export type AkInterface = HTMLElement & {
|
||||
|
||||
const brandContext = Symbol("brandContext");
|
||||
const configContext = Symbol("configContext");
|
||||
const modalController = Symbol("modalController");
|
||||
|
||||
export class Interface extends AKElement implements AkInterface {
|
||||
@state()
|
||||
@ -31,6 +33,8 @@ export class Interface extends AKElement implements AkInterface {
|
||||
|
||||
[configContext]!: ConfigContextController;
|
||||
|
||||
[modalController]!: ModalOrchestrationController;
|
||||
|
||||
@state()
|
||||
config?: Config;
|
||||
|
||||
@ -42,6 +46,7 @@ export class Interface extends AKElement implements AkInterface {
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)];
|
||||
this[brandContext] = new BrandContextController(this);
|
||||
this[configContext] = new ConfigContextController(this);
|
||||
this[modalController] = new ModalOrchestrationController(this);
|
||||
this.dataset.akInterfaceRoot = "true";
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import {
|
||||
ModalHideEvent,
|
||||
ModalShowEvent,
|
||||
} from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
|
||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@ -65,23 +69,10 @@ export class ModalButton extends AKElement {
|
||||
];
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
if (this.handlerBound) return;
|
||||
window.addEventListener("keyup", this.keyUpHandler);
|
||||
this.handlerBound = true;
|
||||
}
|
||||
|
||||
keyUpHandler = (e: KeyboardEvent): void => {
|
||||
if (e.code === "Escape") {
|
||||
closeModal() {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("keyup", this.keyUpHandler);
|
||||
}
|
||||
|
||||
resetForms(): void {
|
||||
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
|
||||
@ -93,6 +84,7 @@ export class ModalButton extends AKElement {
|
||||
|
||||
onClick(): void {
|
||||
this.open = true;
|
||||
this.dispatchEvent(new ModalShowEvent(this));
|
||||
this.querySelectorAll("*").forEach((child) => {
|
||||
if ("requestUpdate" in child) {
|
||||
(child as AKElement).requestUpdate();
|
||||
@ -119,8 +111,7 @@ export class ModalButton extends AKElement {
|
||||
>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
this.dispatchEvent(new ModalHideEvent(this));
|
||||
}}
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
|
122
web/src/elements/controllers/ModalOrchestrationController.ts
Normal file
122
web/src/elements/controllers/ModalOrchestrationController.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { bound } from "@goauthentik/elements/decorators/bound.js";
|
||||
|
||||
import { LitElement, ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
type ReactiveElementHost = Partial<ReactiveControllerHost> & LitElement;
|
||||
|
||||
type ModalElement = LitElement & { closeModal(): void | boolean };
|
||||
|
||||
export class ModalShowEvent extends Event {
|
||||
modal: ModalElement;
|
||||
constructor(modal: ModalElement) {
|
||||
super("ak-modal-show", { bubbles: true, composed: true });
|
||||
this.modal = modal;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModalHideEvent extends Event {
|
||||
modal: ModalElement;
|
||||
constructor(modal: ModalElement) {
|
||||
super("ak-modal-hide", { bubbles: true, composed: true });
|
||||
this.modal = modal;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
"ak-modal-show": ModalShowEvent;
|
||||
"ak-modal-hide": ModalHideEvent;
|
||||
}
|
||||
}
|
||||
|
||||
const modalIsLive = (modal: ModalElement) => modal.isConnected && modal.checkVisibility();
|
||||
|
||||
/**
|
||||
* class ModalOrchetrationController
|
||||
*
|
||||
* A top-level controller that listens for requests from modals to be added to
|
||||
* the management list, such that the *topmost* modal will be closed (and all
|
||||
* references to it eliminated) whenever the user presses the Escape key.
|
||||
* Can also take ModalHideEvent requests and automatically close the modal
|
||||
* sending the event.
|
||||
*
|
||||
* Both events that this responds to expect a reference to the modal to be part
|
||||
* of the event payload.
|
||||
*
|
||||
* If the `.closeModal()` method on the target modal returns `false`
|
||||
* *explicitly*, it will abort cleanup and the stack will keep the record that
|
||||
* the modal is still open. This allows `.closeModal()` to return `undefined`
|
||||
* and still behave correctly.
|
||||
*/
|
||||
|
||||
export class ModalOrchestrationController implements ReactiveController {
|
||||
host!: ReactiveElementHost;
|
||||
|
||||
knownModals: ModalElement[] = [];
|
||||
|
||||
constructor(host: ReactiveElementHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
window.addEventListener("keyup", this.handleKeyup);
|
||||
window.addEventListener("ak-modal-show", this.addModal);
|
||||
window.addEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
window.removeEventListener("keyup", this.handleKeyup);
|
||||
window.removeEventListener("ak-modal-show", this.addModal);
|
||||
window.removeEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
|
||||
@bound
|
||||
addModal(e: ModalShowEvent) {
|
||||
this.knownModals = [...this.knownModals, e.modal];
|
||||
}
|
||||
|
||||
scheduleCleanup(modal: ModalElement) {
|
||||
setTimeout(() => {
|
||||
this.knownModals = this.knownModals.filter((m) => modalIsLive(m) && modal !== m);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@bound
|
||||
closeModal(e: ModalHideEvent) {
|
||||
const modal = e.modal;
|
||||
if (!modalIsLive(modal)) {
|
||||
return;
|
||||
}
|
||||
if (modal.closeModal() !== false) {
|
||||
this.scheduleCleanup(modal);
|
||||
}
|
||||
}
|
||||
|
||||
removeTopmostModal() {
|
||||
let checking = true;
|
||||
while (checking) {
|
||||
const modal = this.knownModals.pop();
|
||||
if (!modal) {
|
||||
break;
|
||||
}
|
||||
if (!modalIsLive(modal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (modal.closeModal() !== false) {
|
||||
this.scheduleCleanup(modal);
|
||||
}
|
||||
checking = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@bound
|
||||
handleKeyup(e: KeyboardEvent) {
|
||||
// The latter handles Firefox 37 and earlier.
|
||||
if (e.key === "Escape" || e.key === "Esc") {
|
||||
this.removeTopmostModal();
|
||||
}
|
||||
}
|
||||
}
|
31
web/src/elements/decorators/bound.ts
Normal file
31
web/src/elements/decorators/bound.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Automatically binds a method to the `this` instance during instantiation.
|
||||
// Uses the Typescript Experimental Decorator syntax, so we may be living with
|
||||
// that for a long time.
|
||||
|
||||
// MDN is *not* very helpful. The type for a PropertyDescriptor is kept in
|
||||
// typescript/lib/lib.es5.d.ts, but the description of what everything in
|
||||
// a descriptor does isn't specified in MDN in its own page, only in
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
|
||||
|
||||
// This decorator feels awkward. It gets a new instance of the method every time
|
||||
// you reference the field. I wonder if there would be a way to create a lookup
|
||||
// table; once you'd bound the method you could reuse that bound method for that
|
||||
// instance, instead of throwing it away?
|
||||
|
||||
export function bound(
|
||||
target: unknown,
|
||||
key: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
if (typeof descriptor?.value !== "function") {
|
||||
throw new Error("Only methods can be @bound.");
|
||||
}
|
||||
return {
|
||||
configurable: true,
|
||||
get() {
|
||||
const method = descriptor.value.bind(this);
|
||||
Object.defineProperty(this, key, { value: method, configurable: true, writable: true });
|
||||
return method;
|
||||
},
|
||||
};
|
||||
}
|
@ -2,6 +2,7 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import "@goauthentik/elements/LoadingOverlay";
|
||||
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import { ModalHideEvent } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -92,8 +93,7 @@ export class ModalForm extends ModalButton {
|
||||
: html``}
|
||||
<ak-spinner-button
|
||||
.callAction=${async () => {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
this.dispatchEvent(new ModalHideEvent(this));
|
||||
}}
|
||||
class="pf-m-secondary"
|
||||
>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { MODAL_BUTTON_STYLES } from "@goauthentik/elements/buttons/ModalButton";
|
||||
import { ModalShowEvent } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
import { Table } from "@goauthentik/elements/table/Table";
|
||||
|
||||
import { CSSResult } from "lit";
|
||||
@ -44,16 +45,6 @@ export abstract class TableModal<T> extends Table<T> {
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("keyup", (e) => {
|
||||
if (e.code === "Escape") {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async fetch(): Promise<void> {
|
||||
if (!this.open) {
|
||||
return;
|
||||
@ -61,6 +52,11 @@ export abstract class TableModal<T> extends Table<T> {
|
||||
return super.fetch();
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
resetForms(): void {
|
||||
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
|
||||
if ("resetForm" in form) {
|
||||
@ -71,6 +67,7 @@ export abstract class TableModal<T> extends Table<T> {
|
||||
|
||||
onClick(): void {
|
||||
this.open = true;
|
||||
this.dispatchEvent(new ModalShowEvent(this));
|
||||
this.querySelectorAll("*").forEach((child) => {
|
||||
if ("requestUpdate" in child) {
|
||||
(child as AKElement).requestUpdate();
|
||||
|
Reference in New Issue
Block a user