diff --git a/src/common/navigate.ts b/src/common/navigate.ts index 53a9497bd621..7bd9c31673e4 100644 --- a/src/common/navigate.ts +++ b/src/common/navigate.ts @@ -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( diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index 49da32e15fa7..a0fc6883d926 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -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; + 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 => { @@ -151,6 +155,46 @@ export const closeDialog = async (dialogTag: string): Promise => { 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) => { + // 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 diff --git a/src/state/url-sync-mixin.ts b/src/state/url-sync-mixin.ts index b9b47d238a47..58594f844b35 100644 --- a/src/state/url-sync-mixin.ts +++ b/src/state/url-sync-mixin.ts @@ -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 | undefined; - -let historyResolve: undefined | (() => void); - export const urlSyncMixin = < T extends Constructor, >( @@ -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 - ) => { - 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(); - } - }; - - 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 (ev.state.opensDialog) { + // coming back from a dialog + // if we are instead navigating forward, the dialogs are already closed + closeLastDialog(); } - if (state.oldState) { - if (DEBUG) { - console.log("handle old state"); - } - this._handleDialogStateChange(state.oldState); + 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); } - 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 }, - "" - ); - } - } + }; };