Simplify dialog navigation to fix back button (#23220)

* Simplify dialog navigation to fix back btn

* add back comment

* manage dialog stack in the manager instead of history

* handle dialogs that refuse to close
This commit is contained in:
Petar Petrov 2024-12-10 13:26:57 +02:00 committed by GitHub
parent 84157c8ea5
commit ed625d4e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 103 additions and 174 deletions

View File

@ -1,4 +1,3 @@
import { historyPromise } from "../state/url-sync-mixin";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
@ -17,11 +16,6 @@ export interface NavigateOptions {
export const navigate = (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false;
if (historyPromise) {
historyPromise.then(() => navigate(path, options));
return;
}
if (__DEMO__) {
if (replace) {
mainWindow.history.replaceState(

View File

@ -37,10 +37,12 @@ export interface DialogClosedParams {
}
export interface DialogState {
dialog: string;
open: boolean;
oldState: null | DialogState;
dialogParams?: unknown;
element: HTMLElement & ProvideHassElement;
root: ShadowRoot | HTMLElement;
dialogTag: string;
dialogParams: unknown;
dialogImport?: () => Promise<unknown>;
addHistory?: boolean;
}
interface LoadedDialogInfo {
@ -53,6 +55,7 @@ interface LoadedDialogsDict {
}
const LOADED: LoadedDialogsDict = {};
const OPEN_DIALOG_STACK: DialogState[] = [];
export const FOCUS_TARGET = Symbol.for("HA focus target");
export const showDialog = async (
@ -77,52 +80,42 @@ export const showDialog = async (
element: dialogImport().then(() => {
const dialogEl = document.createElement(dialogTag) as HassDialog;
element.provideHass(dialogEl);
dialogEl.addEventListener("dialog-closed", _handleClosed);
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
return dialogEl;
}),
};
}
// Get the focus targets after the dialog closes, but keep the original if dialog is being replaced
if (mainWindow.history.state?.replaced) {
LOADED[dialogTag].closedFocusTargets =
LOADED[mainWindow.history.state.dialog].closedFocusTargets;
delete LOADED[mainWindow.history.state.dialog].closedFocusTargets;
} else {
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
deepActiveElement(),
FOCUS_TARGET
);
}
// Get the focus targets after the dialog closes
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
deepActiveElement(),
FOCUS_TARGET
);
const { state } = mainWindow.history;
// if the same dialog is already open, don't push state
if (addHistory) {
mainWindow.history.replaceState(
{
dialog: dialogTag,
open: false,
oldState:
mainWindow.history.state?.open &&
mainWindow.history.state?.dialog !== dialogTag
? mainWindow.history.state
: null,
},
""
);
try {
mainWindow.history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true },
""
);
} catch (err: any) {
// dialogParams could not be cloned, probably contains callback
mainWindow.history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true },
""
);
OPEN_DIALOG_STACK.push({
element,
root,
dialogTag,
dialogParams,
dialogImport,
addHistory,
});
const newState = { dialog: dialogTag };
if (state?.dialog) {
// if the dialog is already open, replace the name
mainWindow.history.replaceState(newState, "");
} else {
// if the dialog is not open, push a new state so back() will close the dialog
mainWindow.history.replaceState({ ...state, opensDialog: true }, "");
mainWindow.history.pushState(newState, "");
}
}
const dialogElement = await LOADED[dialogTag].element;
dialogElement.addEventListener("dialog-closed", _handleClosedFocus);
// Append it again so it's the last element in the root,
// so it's guaranteed to be on top of the other elements
@ -132,12 +125,23 @@ export const showDialog = async (
return true;
};
export const replaceDialog = (dialogElement: HassDialog) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, replaced: true },
""
export const showDialogFromHistory = async (dialogTag: string) => {
const dialogState = OPEN_DIALOG_STACK.find(
(state) => state.dialogTag === dialogTag
);
dialogElement.removeEventListener("dialog-closed", _handleClosedFocus);
if (dialogState) {
showDialog(
dialogState.element,
dialogState.root,
dialogTag,
dialogState.dialogParams,
dialogState.dialogImport,
false
);
} else {
// remove the dialog from history if already closed
mainWindow.history.back();
}
};
export const closeDialog = async (dialogTag: string): Promise<boolean> => {
@ -151,6 +155,46 @@ export const closeDialog = async (dialogTag: string): Promise<boolean> => {
return true;
};
// called on back()
export const closeLastDialog = async () => {
if (OPEN_DIALOG_STACK.length) {
const lastDialog = OPEN_DIALOG_STACK.pop();
const closed = await closeDialog(lastDialog!.dialogTag);
if (!closed) {
// if the dialog was not closed, put it back on the stack
OPEN_DIALOG_STACK.push(lastDialog!);
}
if (OPEN_DIALOG_STACK.length && mainWindow.history.state?.opensDialog) {
// if there are more dialogs open, push a new state so back() will close the next top dialog
mainWindow.history.pushState(
{ dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag },
""
);
}
}
};
const _handleClosed = async (ev: HASSDomEvent<DialogClosedParams>) => {
// If not closed by navigating back, remove the open state from history
const dialogIndex = OPEN_DIALOG_STACK.findIndex(
(state) => state.dialogTag === ev.detail.dialog
);
if (dialogIndex !== -1) {
OPEN_DIALOG_STACK.splice(dialogIndex, 1);
}
if (mainWindow.history.state?.dialog === ev.detail.dialog) {
if (OPEN_DIALOG_STACK.length) {
// if there are more dialogs open, set the top one in history
mainWindow.history.replaceState(
{ dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag },
""
);
} else {
mainWindow.history.back();
}
}
};
export const makeDialogManager = (
element: HTMLElement & ProvideHassElement,
root: ShadowRoot | HTMLElement

View File

@ -1,22 +1,15 @@
/* eslint-disable no-console */
import type { PropertyValueMap, ReactiveElement } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import type {
DialogClosedParams,
DialogState,
import {
closeLastDialog,
showDialogFromHistory,
} from "../dialogs/make-dialog-manager";
import { closeDialog, showDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import type { Constructor } from "../types";
const DEBUG = false;
// eslint-disable-next-line import/no-mutable-exports
export let historyPromise: Promise<void> | undefined;
let historyResolve: undefined | (() => void);
export const urlSyncMixin = <
T extends Constructor<ReactiveElement & ProvideHassElement>,
>(
@ -26,8 +19,6 @@ export const urlSyncMixin = <
__DEMO__
? superClass
: class extends superClass {
private _ignoreNextPopState = false;
public connectedCallback(): void {
super.connectedCallback();
if (mainWindow.history.length === 1) {
@ -37,7 +28,6 @@ export const urlSyncMixin = <
);
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
this.addEventListener("dialog-closed", this._dialogClosedListener);
}
public disconnectedCallback(): void {
@ -46,7 +36,6 @@ export const urlSyncMixin = <
"popstate",
this._popstateChangeListener
);
this.removeEventListener("dialog-closed", this._dialogClosedListener);
}
protected firstUpdated(
@ -54,123 +43,25 @@ export const urlSyncMixin = <
): void {
super.firstUpdated(changedProperties);
if (mainWindow.history.state?.dialog) {
this._handleDialogStateChange(mainWindow.history.state);
showDialogFromHistory(mainWindow.history.state.dialog);
}
}
private _dialogClosedListener = (
ev: HASSDomEvent<DialogClosedParams>
) => {
if (DEBUG) {
console.log("dialog closed", ev.detail.dialog);
console.log(
"open",
mainWindow.history.state?.open,
"dialog",
mainWindow.history.state?.dialog
);
}
// If not closed by navigating back, and not a new dialog is open, remove the open state from history
if (
mainWindow.history.state?.open &&
mainWindow.history.state?.dialog === ev.detail.dialog
) {
if (DEBUG) {
console.log("remove state", ev.detail.dialog);
}
if (mainWindow.history.length) {
this._ignoreNextPopState = true;
historyPromise = new Promise((resolve) => {
historyResolve = () => {
resolve();
historyResolve = undefined;
historyPromise = undefined;
};
mainWindow.history.back();
});
}
}
};
private _popstateChangeListener = (ev: PopStateEvent) => {
if (this._ignoreNextPopState) {
if (
history.length &&
(ev.state?.oldState?.replaced ||
ev.state?.oldState?.dialogParams === null)
) {
// if the previous dialog was replaced, or we could not copy the params, and the current dialog is closed, we should also remove the previous dialog from history
if (DEBUG) {
console.log("remove old state", ev.state.oldState);
}
mainWindow.history.back();
return;
}
if (DEBUG) {
console.log("ignore popstate");
}
this._ignoreNextPopState = false;
if (historyResolve) {
historyResolve();
}
return;
}
if (ev.state && "dialog" in ev.state) {
if (ev.state) {
if (DEBUG) {
console.log("popstate", ev);
}
this._handleDialogStateChange(ev.state);
}
if (historyResolve) {
historyResolve();
if (ev.state.opensDialog) {
// coming back from a dialog
// if we are instead navigating forward, the dialogs are already closed
closeLastDialog();
}
if ("dialog" in ev.state) {
// coming to a dialog
// in practice the dialog stack is empty when navigating forward, so this is a no-op
showDialogFromHistory(ev.state.dialog);
}
}
};
private async _handleDialogStateChange(state: DialogState) {
if (DEBUG) {
console.log("handle state", state);
}
if (!state.open) {
const closed = await closeDialog(state.dialog);
if (!closed) {
if (DEBUG) {
console.log("dialog could not be closed");
}
// dialog could not be closed, push state again
mainWindow.history.pushState(
{
dialog: state.dialog,
open: true,
dialogParams: null,
oldState: null,
},
""
);
return;
}
if (state.oldState) {
if (DEBUG) {
console.log("handle old state");
}
this._handleDialogStateChange(state.oldState);
}
return;
}
let shown = false;
if (state.open && state.dialogParams !== null) {
shown = await showDialog(
this,
this.shadowRoot!,
state.dialog,
state.dialogParams
);
}
if (!shown) {
// can't open dialog, update state
mainWindow.history.replaceState(
{ ...mainWindow.history.state, open: false },
""
);
}
}
};