mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-18 06:46:35 +00:00
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:
parent
84157c8ea5
commit
ed625d4e0b
@ -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(
|
||||||
|
@ -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 =
|
|
||||||
LOADED[mainWindow.history.state.dialog].closedFocusTargets;
|
|
||||||
delete LOADED[mainWindow.history.state.dialog].closedFocusTargets;
|
|
||||||
} else {
|
|
||||||
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
|
LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty(
|
||||||
deepActiveElement(),
|
deepActiveElement(),
|
||||||
FOCUS_TARGET
|
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
|
||||||
|
@ -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 we are instead navigating forward, the dialogs are already closed
|
||||||
|
closeLastDialog();
|
||||||
}
|
}
|
||||||
if (historyResolve) {
|
if ("dialog" in ev.state) {
|
||||||
historyResolve();
|
// 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 },
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user