Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify dialog navigation to fix back button #23220

Merged
merged 5 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions src/common/navigate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { historyPromise } from "../state/url-sync-mixin";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";

Expand All @@ -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(
Expand Down
132 changes: 88 additions & 44 deletions src/dialogs/make-dialog-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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> => {
Expand All @@ -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
Expand Down
137 changes: 14 additions & 123 deletions src/state/url-sync-mixin.ts
Original file line number Diff line number Diff line change
@@ -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>,
>(
Expand All @@ -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) {
Expand All @@ -37,7 +28,6 @@ export const urlSyncMixin = <
);
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
this.addEventListener("dialog-closed", this._dialogClosedListener);
}

public disconnectedCallback(): void {
Expand All @@ -46,131 +36,32 @@ export const urlSyncMixin = <
"popstate",
this._popstateChangeListener
);
this.removeEventListener("dialog-closed", this._dialogClosedListener);
}

protected firstUpdated(
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): 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();
}
};

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not really a no-op, as it will trigger a history.back (causing the forward button to be enabled again) that will then trigger a closeLastDialog which will no-op

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I meant to say nothing will happen from a user perspective but no-op was shorter :D

}
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