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 { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window"; import { mainWindow } from "./dom/get_main_window";
@ -17,11 +16,6 @@ export interface NavigateOptions {
export const navigate = (path: string, options?: NavigateOptions) => { export const navigate = (path: string, options?: NavigateOptions) => {
const replace = options?.replace || false; const replace = options?.replace || false;
if (historyPromise) {
historyPromise.then(() => navigate(path, options));
return;
}
if (__DEMO__) { if (__DEMO__) {
if (replace) { if (replace) {
mainWindow.history.replaceState( mainWindow.history.replaceState(

View File

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

View File

@ -1,22 +1,15 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import type { PropertyValueMap, ReactiveElement } from "lit"; import type { PropertyValueMap, ReactiveElement } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
import type { import {
DialogClosedParams, closeLastDialog,
DialogState, showDialogFromHistory,
} from "../dialogs/make-dialog-manager"; } from "../dialogs/make-dialog-manager";
import { closeDialog, showDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin"; import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import type { Constructor } from "../types"; import type { Constructor } from "../types";
const DEBUG = false; const DEBUG = false;
// eslint-disable-next-line import/no-mutable-exports
export let historyPromise: Promise<void> | undefined;
let historyResolve: undefined | (() => void);
export const urlSyncMixin = < export const urlSyncMixin = <
T extends Constructor<ReactiveElement & ProvideHassElement>, T extends Constructor<ReactiveElement & ProvideHassElement>,
>( >(
@ -26,8 +19,6 @@ export const urlSyncMixin = <
__DEMO__ __DEMO__
? superClass ? superClass
: class extends superClass { : class extends superClass {
private _ignoreNextPopState = false;
public connectedCallback(): void { public connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
if (mainWindow.history.length === 1) { if (mainWindow.history.length === 1) {
@ -37,7 +28,6 @@ export const urlSyncMixin = <
); );
} }
mainWindow.addEventListener("popstate", this._popstateChangeListener); mainWindow.addEventListener("popstate", this._popstateChangeListener);
this.addEventListener("dialog-closed", this._dialogClosedListener);
} }
public disconnectedCallback(): void { public disconnectedCallback(): void {
@ -46,7 +36,6 @@ export const urlSyncMixin = <
"popstate", "popstate",
this._popstateChangeListener this._popstateChangeListener
); );
this.removeEventListener("dialog-closed", this._dialogClosedListener);
} }
protected firstUpdated( protected firstUpdated(
@ -54,123 +43,25 @@ export const urlSyncMixin = <
): void { ): void {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
if (mainWindow.history.state?.dialog) { 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) => { private _popstateChangeListener = (ev: PopStateEvent) => {
if (this._ignoreNextPopState) { if (ev.state) {
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 (DEBUG) { if (DEBUG) {
console.log("popstate", ev); console.log("popstate", ev);
} }
this._handleDialogStateChange(ev.state); if (ev.state.opensDialog) {
} // coming back from a dialog
if (historyResolve) { // if we are instead navigating forward, the dialogs are already closed
historyResolve(); 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 },
""
);
}
}
}; };