Compare commits

..
14 changed files with 132 additions and 440 deletions
@@ -11,7 +11,6 @@ import { mainWindow } from "../common/dom/get_main_window";
import { navigate } from "../common/navigate";
import { showAutomationEditor } from "../data/automation";
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
import { handleNativeBackButtonPressed } from "./external_back_button";
import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
@@ -100,16 +99,6 @@ export const handleExternalMessage = (
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
} else if (msg.command === "back_button/pressed") {
if (!handleNativeBackButtonPressed()) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
return true;
}
} else {
return false;
}
-137
View File
@@ -1,137 +0,0 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { HomeAssistant } from "../types";
import type { ExternalMessaging } from "./external_messaging";
/*
Apps that draw their own toolbar tell us so with `hasNativeBackButton`. We then
hide our own back arrow and instead report whether the current top bar offers a
back action, so the app can show or hide its native button. Tapping that button
sends `back_button/pressed` back to us, so the navigation stays ours.
*/
interface Registration {
bus: ExternalMessaging;
back: () => void;
}
// Top bars that currently offer a back action. The last one to register owns
// the app's back button, so a page mounted on top of another one wins.
const registrations: Registration[] = [];
// The bus we last told to show the button, so we only report changes.
let shownOn: ExternalMessaging | undefined;
const sync = (): void => {
const active = registrations[registrations.length - 1];
if (active) {
if (shownOn !== active.bus) {
active.bus.fireMessage({ type: "back_button/show" });
shownOn = active.bus;
}
return;
}
if (shownOn) {
shownOn.fireMessage({ type: "back_button/hide" });
shownOn = undefined;
}
};
/**
* Run the back action of the top bar that currently owns the app's back
* button. Returns false when no top bar claims one, so the app can be told
* that its button was out of date.
*/
export const handleNativeBackButtonPressed = (): boolean => {
const active = registrations[registrations.length - 1];
if (!active) {
return false;
}
active.back();
return true;
};
interface NativeBackButtonHost extends ReactiveControllerHost {
hass?: HomeAssistant;
}
interface NativeBackButtonOptions {
/** Whether the top bar wants to offer a back action right now. */
visible: () => boolean;
/** Navigates back. Called for both our own arrow and the app's button. */
back: () => void;
}
/**
* Hands the back button of a top bar over to the external app when it renders
* one itself. Hosts must not render their own arrow while `native` is true.
*/
export class NativeBackButtonController implements ReactiveController {
private _registration?: Registration;
constructor(
private _host: NativeBackButtonHost,
private _options: NativeBackButtonOptions
) {
_host.addController(this);
}
/** True while the app renders the back button instead of us. */
public get native(): boolean {
return this._bus !== undefined;
}
private get _bus(): ExternalMessaging | undefined {
// Demo and gallery hosts get by with a partial hass, so tread carefully.
const external = this._host.hass?.auth?.external;
return external?.config.hasNativeBackButton ? external : undefined;
}
public hostConnected(): void {
this._sync();
}
public hostUpdated(): void {
this._sync();
}
public hostDisconnected(): void {
this._unregister();
}
private _sync(): void {
const bus = this._bus;
if (!bus || !this._options.visible()) {
this._unregister();
return;
}
if (this._registration) {
this._registration.bus = bus;
this._registration.back = this._options.back;
} else {
this._registration = { bus, back: this._options.back };
registrations.push(this._registration);
}
sync();
}
private _unregister(): void {
if (!this._registration) {
return;
}
const index = registrations.indexOf(this._registration);
if (index !== -1) {
registrations.splice(index, 1);
}
this._registration = undefined;
sync();
}
}
-18
View File
@@ -218,14 +218,6 @@ interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
type: "frontend/reload_and_clear_cache";
}
interface EMOutgoingMessageBackButtonShow extends EMMessage {
type: "back_button/show"; // The top bar offers a back action; only sent with hasNativeBackButton
}
interface EMOutgoingMessageBackButtonHide extends EMMessage {
type: "back_button/hide";
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
@@ -236,8 +228,6 @@ type EMOutgoingMessageWithoutAnswer =
| EMMessageResultSuccess
| EMOutgoingMessageAppConfiguration
| EMOutgoingMessageAssistShow
| EMOutgoingMessageBackButtonHide
| EMOutgoingMessageBackButtonShow
| EMOutgoingMessageBarCodeClose
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
@@ -357,12 +347,6 @@ export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
command: "improv/device_setup_done";
}
export interface EMIncomingMessageBackButtonPressed {
id: number;
type: "command";
command: "back_button/pressed";
}
export interface EMIncomingMessageKioskModeSet {
id: number;
type: "command";
@@ -385,7 +369,6 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
}
export type EMIncomingMessageCommands =
| EMIncomingMessageBackButtonPressed
| EMIncomingMessageRestart
| EMIncomingMessageNavigate
| EMIncomingMessageShowNotifications
@@ -420,7 +403,6 @@ export interface ExternalConfig {
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
hasNativeBackButton?: boolean; // App draws the back button of the top bar itself, driven by back_button/show and back_button/hide
}
export interface ExternalEntityAddToAction {
+9 -18
View File
@@ -2,22 +2,6 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
/**
* Navigate back the way the toolbar back arrow does, without a click to go by.
* Used by the external app when it renders the back button itself.
*/
export const navigateBack = (
backPath?: string,
backCallback?: () => void
): void => {
if (backCallback) {
backCallback();
return;
}
goBack(sanitizeNavigationPath(backPath));
};
/**
* Shared behavior of the toolbar back arrow. The arrow is a link to the
* declared parent page so it can be opened in a new tab, but a plain click
@@ -28,12 +12,19 @@ export const handleBackClick = (
backPath?: string,
backCallback?: () => void
): void => {
const path = sanitizeNavigationPath(backPath);
// Ctrl, cmd and shift click open the parent in a new tab or window: let
// the anchor handle those. A plain click is handled here instead, and
// isNavigationClick calls preventDefault so the anchor stays inert.
if (sanitizeNavigationPath(backPath) && !isNavigationClick(ev)) {
if (path && !isNavigationClick(ev)) {
return;
}
navigateBack(backPath, backCallback);
if (backCallback) {
backCallback();
return;
}
goBack(path);
};
+9 -24
View File
@@ -1,13 +1,12 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { getHistoryState } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -32,18 +31,6 @@ class HassSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
@@ -51,16 +38,14 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
!this._showsBackButton
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
+8 -23
View File
@@ -17,8 +17,7 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
import { getHistoryState, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import { NativeBackButtonController } from "../external_app/external_back_button";
import { handleBackClick, navigateBack } from "./back-navigation";
import { handleBackClick } from "./back-navigation";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -95,18 +94,6 @@ export class HassTabsSubpage extends LitElement {
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._showsBackButton,
back: () => navigateBack(this.backPath, this.backCallback),
});
private get _showsBackButton(): boolean {
return (
!this.mainPage &&
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
);
}
private _getTabs = memoizeOne(
(
tabs: PageNavigation[],
@@ -187,16 +174,14 @@ export class HassTabsSubpage extends LitElement {
<slot name="toolbar">
<div class="toolbar-content">
${
!this._showsBackButton
this.mainPage || (!backPath && getHistoryState()?.root)
? html`<ha-menu-button></ha-menu-button>`
: this._nativeBackButton.native
? nothing
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -30,6 +30,7 @@ import {
} from "../../../data/config_flow";
import type { IntegrationManifest } from "../../../data/integration";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showMatterAddDeviceDialog } from "./integration-panels/matter/show-dialog-add-matter-device";
import {
showAlertDialog,
showConfirmationDialog,
@@ -182,6 +183,16 @@ export class HaConfigFlowCard extends LitElement {
});
return;
}
// A discovered Matter device is commissioned through the Matter dialog;
// other steps of a Bluetooth sourced flow set up the integration itself.
if (
this.flow.handler === "matter" &&
this.flow.context.source === "bluetooth" &&
this.flow.step_id === "bluetooth_confirm"
) {
showMatterAddDeviceDialog(this, { discoveryFlowId: this.flow.flow_id });
return;
}
showConfigFlowDialog(this, {
continueFlowId: this.flow.flow_id,
navigateToResult: true,
@@ -12,10 +12,13 @@ import "../../../../../components/ha-icon-button-arrow-prev";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import type { MatterCommissionFinish } from "../../../../../external_app/external_messaging";
import { handleConfigFlowStep } from "../../../../../data/config_flow";
import {
canCommissionMatterExternal,
commissionMatterDevice,
watchForNewMatterDevice,
} from "../../../../../data/matter";
import type { MatterAddDeviceDialogParams } from "./show-dialog-add-matter-device";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
@@ -37,6 +40,7 @@ import "./matter-add-device/matter-add-device-main";
import "./matter-add-device/matter-add-device-new";
import "./matter-add-device/matter-add-device-commissioning";
import "./matter-add-device/matter-add-device-device-added";
import "./matter-add-device/matter-add-device-discovered";
import { showToast } from "../../../../../util/toast";
export type MatterAddDeviceStep =
@@ -48,7 +52,8 @@ export type MatterAddDeviceStep =
| "apple_home"
| "generic"
| "commissioning"
| "device_added";
| "device_added"
| "discovered";
declare global {
interface HASSDomEvents {
@@ -68,6 +73,7 @@ const BACK_STEP: Record<MatterAddDeviceStep, MatterAddDeviceStep | undefined> =
generic: "existing",
commissioning: undefined,
device_added: undefined,
discovered: undefined,
};
@customElement("dialog-matter-add-device")
@@ -104,8 +110,15 @@ class DialogMatterAddDevice extends LitElement {
private _unsub?: UnsubscribeFunc;
public showDialog(): void {
private _discoveryFlowId?: string;
public showDialog(params: MatterAddDeviceDialogParams): void {
this._discoveryFlowId = params.discoveryFlowId;
this._open = true;
// A discovered device only needs its code, unless the app can scan it.
if (this._discoveryFlowId && !canCommissionMatterExternal(this.hass)) {
this._step = "discovered";
}
this._unsub = watchForNewMatterDevice(this.hass, (device) => {
// make sure a refresh of the page will navigate to the device page, old iOS apps will refresh the webview when commissioning is done
setRefreshUrl(`/config/devices/device/${device.id}`);
@@ -211,6 +224,7 @@ class DialogMatterAddDevice extends LitElement {
this._open = false;
this._step = "main";
this._pairingCode = "";
this._discoveryFlowId = undefined;
this._newDevice = undefined;
this._mainEntity = undefined;
this._mainEntityFetched = false;
@@ -283,7 +297,7 @@ class DialogMatterAddDevice extends LitElement {
const savedStep = this._step;
try {
this._step = "commissioning";
await commissionMatterDevice(this.hass, code, true);
await this._commission(code);
} catch (_err) {
showToast(this, {
message: this.hass.localize(
@@ -296,6 +310,20 @@ class DialogMatterAddDevice extends LitElement {
// On success, keep showing commissioning spinner until watchForNewMatterDevice fires
}
private async _commission(code: string): Promise<void> {
if (!this._discoveryFlowId) {
await commissionMatterDevice(this.hass, code, true);
return;
}
// The discovery flow commissions over Bluetooth and ends itself on success.
const step = await handleConfigFlowStep(this.hass, this._discoveryFlowId, {
code,
});
if (step.type !== "abort" || step.reason !== "commission_successful") {
throw new Error("Commissioning failed");
}
}
private async _finishDeviceAdded(): Promise<void> {
const device = this._newDevice!;
const { name, area, deviceClass, hasPendingUpdates } =
@@ -377,7 +405,8 @@ class DialogMatterAddDevice extends LitElement {
if (
this._step === "apple_home" ||
this._step === "google_home_fallback" ||
this._step === "generic"
this._step === "generic" ||
this._step === "discovered"
) {
return html`
<ha-button
@@ -0,0 +1,46 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import "../../../../../../components/input/ha-input";
import type { HomeAssistant } from "../../../../../../types";
import { sharedStyles } from "./matter-add-device-shared-styles";
@customElement("matter-add-device-discovered")
class MatterAddDeviceDiscovered extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _code = "";
render() {
return html`
<div class="content">
<p>
${this.hass.localize(
"ui.dialogs.matter-add-device.discovered.code_instructions"
)}
</p>
<ha-input
label=${this.hass.localize(
"ui.dialogs.matter-add-device.discovered.setup_code"
)}
.value=${this._code}
@input=${this._onCodeChanged}
></ha-input>
</div>
`;
}
private _onCodeChanged(ev: InputEvent) {
const value = (ev.target as HTMLInputElement).value;
this._code = value;
fireEvent(this, "pairing-code-changed", { code: value });
}
static styles = [sharedStyles];
}
declare global {
interface HTMLElementTagNameMap {
"matter-add-device-discovered": MatterAddDeviceDiscovered;
}
}
@@ -1,11 +1,19 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
export interface MatterAddDeviceDialogParams {
/** Bluetooth discovery flow to commission through, if any. */
discoveryFlowId?: string;
}
export const loadAddDeviceDialog = () => import("./dialog-matter-add-device");
export const showMatterAddDeviceDialog = (element: HTMLElement): void => {
export const showMatterAddDeviceDialog = (
element: HTMLElement,
dialogParams: MatterAddDeviceDialogParams = {}
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-matter-add-device",
dialogImport: loadAddDeviceDialog,
dialogParams: {},
dialogParams,
});
};
@@ -16,7 +16,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-top-app-bar-fixed";
import { NativeBackButtonController } from "../../external_app/external_back_button";
import "../../components/media-player/ha-media-manage-button";
import "../../components/media-player/ha-media-player-browse";
import type {
@@ -90,16 +89,11 @@ class PanelMediaBrowser extends LitElement {
@query("ha-bar-media-player") private _player!: BarMediaPlayer;
private _nativeBackButton = new NativeBackButtonController(this, {
visible: () => this._navigateIds.length > 1,
back: () => this._goBack(),
});
protected render(): TemplateResult {
return html`
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${
this._navigateIds.length > 1 && !this._nativeBackButton.native
this._navigateIds.length > 1
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
+5
View File
@@ -2514,6 +2514,11 @@
"code_instructions": "Search for the sharing mode in the app of your controller, and activate it. You will get a setup code, enter that below.",
"setup_code": "Setup code"
},
"discovered": {
"header": "Add discovered device",
"code_instructions": "This device was found nearby over Bluetooth. Enter the setup code printed on the device or its packaging, or the text of its QR code.",
"setup_code": "[%key:ui::dialogs::matter-add-device::generic::setup_code%]"
},
"device_added": {
"header": "Device added",
"finish": "Finish",
@@ -7,7 +7,6 @@ import {
handleExternalMessage,
addExternalBarCodeListener,
} from "../../src/external_app/external_app_entrypoint";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import { showAutomationEditor } from "../../src/data/automation";
import type {
EMIncomingMessageRestart,
@@ -20,7 +19,6 @@ import type {
EMIncomingMessageImprovDeviceSetupDone,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBackButtonPressed,
} from "../../src/external_app/external_messaging";
vi.mock("../../src/common/dom/fire_event", () => ({
@@ -32,9 +30,6 @@ vi.mock("../../src/common/navigate", () => ({
vi.mock("../../src/data/automation", () => ({
showAutomationEditor: vi.fn(),
}));
vi.mock("../../src/external_app/external_back_button", () => ({
handleNativeBackButtonPressed: vi.fn(),
}));
describe("handleExternalMessage", () => {
let hassMainEl: any;
@@ -290,38 +285,4 @@ describe("handleExternalMessage", () => {
});
expect(result).toBe(true);
});
it("handles back_button/pressed command", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(true);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 13,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(handleNativeBackButtonPressed).toHaveBeenCalledOnce();
expect(fireMessage).toHaveBeenCalledWith({
id: 13,
type: "result",
success: true,
result: null,
});
expect(result).toBe(true);
});
it("reports back_button/pressed without a back button as an error", () => {
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(false);
const msg: EMIncomingMessageBackButtonPressed = {
type: "command",
command: "back_button/pressed",
id: 14,
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
id: 14,
type: "result",
success: false,
error: { code: "not_allowed", message: "no back button shown" },
});
expect(result).toBe(true);
});
});
@@ -1,157 +0,0 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
import type { ExternalMessaging } from "../../src/external_app/external_messaging";
import type { HomeAssistant } from "../../src/types";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/common/navigate", () => ({
getHistoryState: () => undefined,
goBack: vi.fn(),
}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
await import("../../src/layouts/hass-subpage");
const { goBack } = await import("../../src/common/navigate");
let fireMessage: ReturnType<typeof vi.fn>;
let nativeBus: ExternalMessaging;
// The app has a single external bus, so every page shares one.
const makeHass = (hasNativeBackButton: boolean): HomeAssistant =>
({
auth: {
external: hasNativeBackButton
? nativeBus
: ({
config: {},
fireMessage,
} as unknown as ExternalMessaging),
},
}) as HomeAssistant;
let host: HTMLDivElement | undefined;
const mount = async (hass: HomeAssistant, backPath = "/config") => {
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, backPath });
host!.append(element);
await (element as LitElement).updateComplete;
return element;
};
const arrowOf = (element: Element) =>
element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
beforeEach(() => {
fireMessage = vi.fn();
nativeBus = {
config: { hasNativeBackButton: true },
fireMessage,
} as unknown as ExternalMessaging;
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("native back button", () => {
it("keeps rendering the arrow when the app has no native back button", async () => {
const element = await mount(makeHass(false));
expect(arrowOf(element)).not.toBeNull();
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("hides the arrow and reports the back button to the app", async () => {
const element = await mount(makeHass(true));
expect(arrowOf(element)).toBeNull();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
});
it("hides the app back button once the page is gone", async () => {
const element = await mount(makeHass(true));
fireMessage.mockClear();
element.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/hide",
});
});
it("does not report a back button on a main page", async () => {
const hass = makeHass(true);
const element = document.createElement("hass-subpage");
Object.assign(element, { hass, mainPage: true });
host!.append(element);
await (element as LitElement).updateComplete;
expect(fireMessage).not.toHaveBeenCalled();
expect(handleNativeBackButtonPressed()).toBe(false);
});
it("navigates back when the app reports a press", async () => {
await mount(makeHass(true), "/config/areas");
expect(handleNativeBackButtonPressed()).toBe(true);
expect(goBack).toHaveBeenCalledWith("/config/areas");
});
it("uses the back callback of the page when it has one", async () => {
const backCallback = vi.fn();
const element = await mount(makeHass(true));
Object.assign(element, { backCallback });
await (element as LitElement).updateComplete;
expect(handleNativeBackButtonPressed()).toBe(true);
expect(backCallback).toHaveBeenCalledOnce();
expect(goBack).not.toHaveBeenCalled();
});
it("lets the page mounted last own the back button", async () => {
const first = vi.fn();
const second = vi.fn();
const firstPage = await mount(makeHass(true));
Object.assign(firstPage, { backCallback: first });
await (firstPage as LitElement).updateComplete;
const secondPage = await mount(makeHass(true));
Object.assign(secondPage, { backCallback: second });
await (secondPage as LitElement).updateComplete;
handleNativeBackButtonPressed();
expect(second).toHaveBeenCalledOnce();
expect(first).not.toHaveBeenCalled();
// Back on the first page, it owns the button again.
secondPage.remove();
handleNativeBackButtonPressed();
expect(first).toHaveBeenCalledOnce();
});
it("only reports the back button once while pages come and go", async () => {
const firstPage = await mount(makeHass(true));
const secondPage = await mount(makeHass(true));
firstPage.remove();
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
type: "back_button/show",
});
secondPage.remove();
expect(fireMessage).toHaveBeenLastCalledWith({ type: "back_button/hide" });
});
});