Compare commits

...
Author SHA1 Message Date
Bruno Pantaleão GonçalvesandClaude Opus 5 59c51007c2 Document kiosk element dependencies on app panel header and edit mode
- app_panel_header holds the app panel's sidebar button, so hiding the header
  hides that button too. Keeping the button without its header would need a
  floating control over the add-on iframe, which kiosk mode never had; a
  client can still open the sidebar with sidebar/show or sidebar/toggle.
- dashboard_tabs is only honored outside edit mode, where the tabs are the
  view editor (select, move, edit and add views). A client keeping the tabs
  out of reach should also hide dashboard_edit_button.

Documented on KIOSK_ELEMENTS, on the kiosk_mode/set contract and next to the
code in ha-panel-app and hui-root.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 19:13:27 +02:00
Bruno Pantaleão GonçalvesandClaude Opus 5 050edb3f01 Address review feedback on kiosk element selection
- Keep kiosk requests per source (companion app, app panel add-on) and hide
  their union, so an add-on's kiosk request still hides the app panel header
  on top of a narrower companion app selection, and the companion app's
  selection is restored when the add-on lets go.
- Show the sidebar opener on wide screens when the sidebar is hidden, both in
  ha-menu-button and in the app panel header, and honor a hidden
  sidebar_button in the app panel header.
- Type kiosk_mode/set element lists as string[], matching the forward
  compatible handling of unknown element names.
- Cover kiosk_mode/set in the external app entrypoint tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 13:09:21 +02:00
Bruno Pantaleão Gonçalves f8b6605915 Merge branch 'dev' into kiosk-hidden-elements 2026-09-14 10:13:12 +02:00
Bruno Pantaleão 22d48fbfdd Let kiosk clients choose which parts of the UI are hidden
Kiosk mode was a single boolean: a client could ask for kiosk mode and got
one fixed bundle of hidden UI, with no say over what it hid. The iOS app
enables it purely to suppress the hamburger for the native tab bar and
inherits the hidden add, search and edit buttons as a side effect.

Name the hideable parts in KIOSK_ELEMENTS and let `kiosk_mode/set` carry
either `excluded_elements` (hide exactly these) or `included_elements`
(hide everything but these). Sending neither keeps the set kiosk mode hid
before, so clients that only send `enable` are unaffected; sending both
fails the command, since the two lists describe the same set and a message
carrying both has no single meaning.

Two parts are newly hideable rather than newly configurable: the Assist
button, previously the only toolbar button kiosk mode never touched, and
the view tabs, which give way to the current view's title. Neither is in
the default set.

The app panel now reads the kiosk state during render instead of caching
it on a hass change, so opening an ingress add-on while kiosk mode is
already on hides its header on the first paint rather than never.
2026-09-13 16:48:42 +00:00
15 changed files with 473 additions and 37 deletions
+2
View File
@@ -10,6 +10,7 @@ import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_eleme
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../../src/data/kiosk_mode";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
@@ -633,6 +634,7 @@ class HaGallery extends LitElement {
floors: {},
hassUrl: (path) => path,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
language: "en",
loadBackendTranslation: async () => galleryLocalize,
loadFragmentTranslation: async () => undefined,
+10 -3
View File
@@ -65,7 +65,9 @@ class HaMenuButton extends LitElement {
}
const hasNotifications =
this._hasNotifications &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
return html`
<ha-icon-button
.label=${this._localize("ui.sidebar.sidebar_toggle")}
@@ -92,9 +94,14 @@ class HaMenuButton extends LitElement {
this._unsubNotifications = undefined;
}
// A hidden sidebar is only reachable as an overlay, so it needs this
// button on wide screens too, unless the button itself is hidden.
const showButton =
this._ui?.kioskMode === false &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
!!this._ui &&
!this._ui.kioskElementsHidden.has("sidebar_button") &&
(this._narrow ||
this._ui.dockedSidebar === "always_hidden" ||
this._ui.kioskElementsHidden.has("sidebar"));
this._show = showButton || this._alwaysVisible;
+2
View File
@@ -115,6 +115,7 @@ const updateUi = (
value.panelUrl !== hass.panelUrl ||
value.dockedSidebar !== hass.dockedSidebar ||
value.kioskMode !== hass.kioskMode ||
value.kioskElementsHidden !== hass.kioskElementsHidden ||
value.enableShortcuts !== hass.enableShortcuts ||
value.vibrate !== hass.vibrate ||
value.suspendWhenHidden !== hass.suspendWhenHidden
@@ -126,6 +127,7 @@ const updateUi = (
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
kioskElementsHidden: hass.kioskElementsHidden,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
+132
View File
@@ -0,0 +1,132 @@
/**
* The parts of the UI a kiosk client can hide.
*
* A client names them over the external bus (`kiosk_mode/set`) as either
* `excluded_elements` (hide exactly these) or `included_elements` (hide
* everything except these), so the same set can be described from either side.
*/
export const KIOSK_ELEMENTS = [
/** The sidebar itself: never docked, only reachable as an overlay. */
"sidebar",
/**
* The hamburger button that opens the sidebar, in every panel's toolbar.
* In the app panel it lives in `app_panel_header`, so hiding that header
* hides this button too, whatever this element is set to.
*/
"sidebar_button",
/**
* The dashboard view tabs. The current view's title is shown instead.
* Only outside edit mode: in edit mode the tabs are how views are selected,
* moved, edited and added, so they stay. Hide `dashboard_edit_button` as well
* to keep that button from being a way back to them.
*/
"dashboard_tabs",
/** The "+" menu that adds a device, automation, area or person. */
"dashboard_add_button",
/** The search button that opens the quick bar. */
"dashboard_search_button",
/** The Assist button. */
"dashboard_assist_button",
/** The pencil that switches the dashboard into edit mode. */
"dashboard_edit_button",
/**
* The header the app panel draws above an ingress add-on iframe, including
* its sidebar button. With it hidden the iframe takes the full height; a
* client can still open the sidebar with `sidebar/show` or `sidebar/toggle`.
*/
"app_panel_header",
] as const;
export type KioskElement = (typeof KIOSK_ELEMENTS)[number];
const KIOSK_ELEMENTS_SET: ReadonlySet<string> = new Set(KIOSK_ELEMENTS);
/**
* Nothing hidden. Shared instance so context consumers that compare by
* reference don't see a change every time kiosk mode is switched off.
*/
export const NO_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set();
/**
* What a client gets when it enables kiosk mode without naming any elements.
* This is the set kiosk mode hid back when it was a single boolean, so a client
* that only sends `enable` keeps the behavior it has today.
*/
export const DEFAULT_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set(
[
"sidebar",
"sidebar_button",
"dashboard_add_button",
"dashboard_search_button",
"dashboard_edit_button",
"app_panel_header",
]
);
/**
* Who asked for kiosk mode. Each source's request is kept on its own and the
* hidden elements are their union, so one source switching kiosk mode off
* doesn't undo what another still asks for.
*
* - `external_app`: the companion app, over `kiosk_mode/set`.
* - `app_panel`: an ingress add-on, via `home-assistant/subscribe-properties`.
*/
export type KioskModeSource = "external_app" | "app_panel";
export interface KioskModeParams {
enable: boolean;
/** Defaults to `external_app`. */
source?: KioskModeSource;
/** Hide exactly these. Mutually exclusive with `includedElements`. */
excludedElements?: readonly string[];
/** Hide everything except these. Mutually exclusive with `excludedElements`. */
includedElements?: readonly string[];
}
const isKioskElement = (value: string): value is KioskElement =>
KIOSK_ELEMENTS_SET.has(value);
/**
* Resolve a kiosk request into the elements to hide.
*
* Element names this frontend doesn't know are ignored rather than rejected, so
* a newer client naming an element an older frontend has never heard of still
* gets the rest of its request honored.
*/
export const resolveKioskElementsHidden = ({
enable,
excludedElements,
includedElements,
}: KioskModeParams): ReadonlySet<KioskElement> => {
if (!enable) {
return NO_KIOSK_ELEMENTS_HIDDEN;
}
if (excludedElements) {
return new Set(excludedElements.filter(isKioskElement));
}
if (includedElements) {
const shown = new Set(includedElements);
return new Set(KIOSK_ELEMENTS.filter((element) => !shown.has(element)));
}
return DEFAULT_KIOSK_ELEMENTS_HIDDEN;
};
/**
* Combine the elements each kiosk source hides into the set the UI hides.
*
* Returns a source's set unchanged when it is the only one hiding anything, and
* the shared empty set when none is, so context consumers comparing by
* reference only see a change when the combined result actually changes shape.
*/
export const combineKioskElementsHidden = (
requests: Iterable<ReadonlySet<KioskElement>>
): ReadonlySet<KioskElement> => {
const nonEmpty = [...requests].filter((request) => request.size > 0);
if (nonEmpty.length === 0) {
return NO_KIOSK_ELEMENTS_HIDDEN;
}
if (nonEmpty.length === 1) {
return nonEmpty[0];
}
return new Set(nonEmpty.flatMap((request) => [...request]));
};
+21 -1
View File
@@ -98,7 +98,27 @@ export const handleExternalMessage = (
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
const { enable, excluded_elements, included_elements } = msg.payload;
// The two lists are two ways of describing the same set, so a message
// carrying both has no single meaning we could act on.
if (excluded_elements && included_elements) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: {
code: "invalid_format",
message:
"Pass either excluded_elements or included_elements, not both",
},
});
return true;
}
fireEvent(window, "hass-kiosk-mode", {
enable,
excludedElements: excluded_elements,
includedElements: included_elements,
});
} else {
return false;
}
+21
View File
@@ -325,8 +325,29 @@ export interface EMIncomingMessageKioskModeSet {
id: number;
type: "command";
command: "kiosk_mode/set";
/**
* Element names are listed in `KIOSK_ELEMENTS`. Two of them depend on
* another element:
* - `app_panel_header` holds the app panel's sidebar button, so hiding the
* header hides that button even when `sidebar_button` is not hidden.
* - `dashboard_tabs` is only hidden outside edit mode, where the tabs are the
* view editor; hide `dashboard_edit_button` too to keep them out of reach.
*/
payload: {
enable: boolean;
/**
* Hide exactly these parts of the UI. Mutually exclusive with
* `included_elements`; sending both fails the command. Plain strings, as a
* newer client may name elements this frontend doesn't know; those are
* ignored (see `resolveKioskElementsHidden`).
*/
excluded_elements?: string[];
/**
* Hide every part of the UI except these. Mutually exclusive with
* `excluded_elements`; sending both fails the command. Unknown names are
* ignored, as for `excluded_elements`.
*/
included_elements?: string[];
};
}
+2
View File
@@ -24,6 +24,7 @@ import {
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
import type { IconCategory } from "../data/icons";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
DateFormat,
@@ -464,6 +465,7 @@ export const provideHass = (
vibrate: true,
debugConnection: false,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
suspendWhenHidden: false,
// @ts-ignore
async callService(domain, service, data, target, _notify, returnResponse) {
+7 -3
View File
@@ -51,7 +51,9 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult {
const sidebarNarrow =
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode;
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar");
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
@@ -105,7 +107,7 @@ export class HomeAssistantMain extends LitElement {
});
return;
}
if (this._sidebarNarrow || this.hass.kioskMode) {
if (this._sidebarNarrow || this.hass.kioskElementsHidden.has("sidebar")) {
this._drawerOpen = ev.detail?.open ?? !this._drawerOpen;
} else {
fireEvent(this, "hass-dock-sidebar", {
@@ -144,7 +146,9 @@ export class HomeAssistantMain extends LitElement {
this.toggleAttribute(
"modal",
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar")
);
}
+44 -20
View File
@@ -49,8 +49,6 @@ class HaPanelApp extends LitElement {
@state() private _loadingMessage?: string;
@state() private _kioskMode = false;
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
@@ -83,11 +81,6 @@ class HaPanelApp extends LitElement {
) {
this._sendPropertiesToIframe();
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.kioskMode !== this.hass.kioskMode) {
this._kioskMode = this.hass.kioskMode;
}
}
public connectedCallback() {
@@ -110,7 +103,10 @@ class HaPanelApp extends LitElement {
this._fetchDataTimeout = undefined;
}
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
}
}
@@ -121,18 +117,35 @@ class HaPanelApp extends LitElement {
></hass-loading-screen>`;
}
// The sidebar button only exists inside this header, so hiding the header
// hides the button too (documented on `app_panel_header`). Keeping it would
// need a floating control over the add-on, which kiosk mode never had.
const hideHeader = this.hass.kioskElementsHidden.has("app_panel_header");
const sidebarHidden = this.hass.kioskElementsHidden.has("sidebar");
const hideMenuButton = this.hass.kioskElementsHidden.has("sidebar_button");
// Make sure this all is 1 template so hiding toolbar doesn't reload iframe
return html`
${
!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
!hideHeader &&
(this.narrow ||
this.hass.dockedSidebar === "always_hidden" ||
sidebarHidden)
? html`
<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
${
hideMenuButton
? nothing
: html`
<ha-icon-button
.label=${this.hass.localize(
"ui.sidebar.sidebar_toggle"
)}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
`
}
<div class="main-title">${this._addon.name}</div>
</div>
`
@@ -141,7 +154,7 @@ class HaPanelApp extends LitElement {
<iframe
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"kiosk-mode": hideHeader,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
@@ -183,7 +196,10 @@ class HaPanelApp extends LitElement {
this._iframeLoaded = false;
// Reset state when switching apps
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
this._enabledKioskMode = false;
}
this._iframeSubscribeUpdates = false;
@@ -429,9 +445,14 @@ class HaPanelApp extends LitElement {
// safe area itself; we then forward the inset values below.
this._handleSafeArea = !!data.handleSafeArea;
this._sendPropertiesToIframe();
if (data.kioskMode && !this.hass.kioskMode) {
// Kept apart from the companion app's own kiosk request, so it adds
// to that selection and leaves it in place when the addon lets go.
if (data.kioskMode && !this._enabledKioskMode) {
this._enabledKioskMode = true;
fireEvent(window, "hass-kiosk-mode", { enable: true });
fireEvent(window, "hass-kiosk-mode", {
enable: true,
source: "app_panel",
});
}
break;
@@ -439,7 +460,10 @@ class HaPanelApp extends LitElement {
this._iframeSubscribeUpdates = false;
this._handleSafeArea = false;
if (this._enabledKioskMode) {
fireEvent(window, "hass-kiosk-mode", { enable: false });
fireEvent(window, "hass-kiosk-mode", {
enable: false,
source: "app_panel",
});
this._enabledKioskMode = false;
}
break;
+24 -8
View File
@@ -257,7 +257,9 @@ class HUIRoot extends LitElement {
icon: mdiPlus,
key: "ui.panel.lovelace.menu.add",
visible:
!this._editMode && this.hass.user?.is_admin && !this.hass.kioskMode,
!this._editMode &&
this.hass.user?.is_admin &&
!this.hass.kioskElementsHidden.has("dashboard_add_button"),
overflow: this.narrow,
subItems: [
{
@@ -300,7 +302,9 @@ class HUIRoot extends LitElement {
? "(⌘ + K)"
: "(Ctrl + K)"
: undefined,
visible: !this._editMode && !this.hass.kioskMode,
visible:
!this._editMode &&
!this.hass.kioskElementsHidden.has("dashboard_search_button"),
overflow: this.narrow,
},
{
@@ -311,7 +315,9 @@ class HUIRoot extends LitElement {
suffix:
this.hass.enableShortcuts && !isMobileClient ? "(A)" : undefined,
visible:
!this._editMode && this._conversation(this.hass.config.components),
!this._editMode &&
this._conversation(this.hass.config.components) &&
!this.hass.kioskElementsHidden.has("dashboard_assist_button"),
overflow: this.narrow,
},
{
@@ -347,7 +353,7 @@ class HUIRoot extends LitElement {
!this._editMode &&
this.hass!.user?.is_admin &&
!this.hass!.config.recovery_mode &&
!this.hass.kioskMode &&
!this.hass.kioskElementsHidden.has("dashboard_edit_button") &&
!this.noEdit,
overflow: true,
overflow_can_promote: true,
@@ -549,6 +555,10 @@ class HUIRoot extends LitElement {
const isSubview = curViewConfig?.subview;
const hasTabViews = views.filter((view) => !view.subview).length > 1;
// Only honored outside edit mode. In edit mode the tabs are the view
// editor (select, move, edit and add views), so hiding them there would
// make views uneditable; see `dashboard_tabs` in data/kiosk_mode.
const hideTabs = this.hass.kioskElementsHidden.has("dashboard_tabs");
return html`
<div
@@ -605,13 +615,19 @@ class HUIRoot extends LitElement {
${curViewConfig.title}
</div>
`
: hasTabViews
? tabs
: html`
: hideTabs
? html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
${curViewConfig?.title ?? dashboardTitle}
</div>
`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`
}
<div class="action-items">
${this._renderActionItems()}
+2
View File
@@ -18,6 +18,7 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import { serviceCallWillDisconnect } from "../data/service";
import {
DateFormat,
@@ -85,6 +86,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
localize: () => "",
translationMetadata,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
dockedSidebar: "docked",
vibrate: true,
debugConnection: __DEV__,
+32 -2
View File
@@ -1,5 +1,14 @@
import type { PropertyValues } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type {
KioskElement,
KioskModeParams,
KioskModeSource,
} from "../data/kiosk_mode";
import {
combineKioskElementsHidden,
resolveKioskElementsHidden,
} from "../data/kiosk_mode";
import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -12,7 +21,7 @@ declare global {
// for fire event
interface HASSDomEvents {
"hass-dock-sidebar": DockSidebarParams;
"hass-kiosk-mode": { enable: boolean };
"hass-kiosk-mode": KioskModeParams;
}
// for add event listener
interface HTMLElementEventMap {
@@ -25,6 +34,13 @@ declare global {
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
// What each source currently asks kiosk mode to hide. A source is only in
// here while it has kiosk mode enabled.
private __kioskRequests = new Map<
KioskModeSource,
ReadonlySet<KioskElement>
>();
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("hass-dock-sidebar", (ev) => {
@@ -32,7 +48,21 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
storeState(this.hass!);
});
window.addEventListener("hass-kiosk-mode", (ev) => {
this._updateHass({ kioskMode: ev.detail.enable });
const source = ev.detail.source ?? "external_app";
if (ev.detail.enable) {
this.__kioskRequests.set(
source,
resolveKioskElementsHidden(ev.detail)
);
} else {
this.__kioskRequests.delete(source);
}
this._updateHass({
kioskMode: this.__kioskRequests.size > 0,
kioskElementsHidden: combineKioskElementsHidden(
this.__kioskRequests.values()
),
});
});
}
};
+3
View File
@@ -18,6 +18,7 @@ import type { AreaRegistryEntry } from "./data/area/area_registry";
import type { DeviceRegistryEntry } from "./data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "./data/entity/entity_registry";
import type { FloorRegistryEntry } from "./data/floor_registry";
import type { KioskElement } from "./data/kiosk_mode";
import type {
CoreFrontendSystemData,
CoreFrontendUserData,
@@ -305,6 +306,8 @@ export interface HomeAssistantUI {
panelUrl: string;
dockedSidebar: "docked" | "always_hidden" | "auto";
kioskMode: boolean;
/** Parts of the UI a kiosk client asked to hide. Empty when kiosk mode is off. */
kioskElementsHidden: ReadonlySet<KioskElement>;
enableShortcuts: boolean;
vibrate: boolean;
suspendWhenHidden: boolean;
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { KioskElement } from "../../src/data/kiosk_mode";
import {
combineKioskElementsHidden,
DEFAULT_KIOSK_ELEMENTS_HIDDEN,
KIOSK_ELEMENTS,
NO_KIOSK_ELEMENTS_HIDDEN,
resolveKioskElementsHidden,
} from "../../src/data/kiosk_mode";
const hidden = (params: Parameters<typeof resolveKioskElementsHidden>[0]) =>
[...resolveKioskElementsHidden(params)].sort();
describe("resolveKioskElementsHidden", () => {
it("hides nothing when kiosk mode is off, whatever the client listed", () => {
expect(
resolveKioskElementsHidden({
enable: false,
excludedElements: ["sidebar"],
})
).toBe(NO_KIOSK_ELEMENTS_HIDDEN);
});
it("falls back to the default set when no elements are named", () => {
expect(resolveKioskElementsHidden({ enable: true })).toBe(
DEFAULT_KIOSK_ELEMENTS_HIDDEN
);
});
it("hides exactly the excluded elements", () => {
expect(
hidden({
enable: true,
excludedElements: ["sidebar_button", "dashboard_edit_button"],
})
).toEqual(["dashboard_edit_button", "sidebar_button"]);
});
it("hides everything but the included elements", () => {
const shown: KioskElement[] = ["sidebar", "dashboard_tabs"];
expect(hidden({ enable: true, includedElements: shown })).toEqual(
KIOSK_ELEMENTS.filter((element) => !shown.includes(element)).sort()
);
});
it("hides everything when the included list is empty", () => {
expect(hidden({ enable: true, includedElements: [] })).toEqual(
[...KIOSK_ELEMENTS].sort()
);
});
// A newer client may name elements this frontend has never heard of; the rest
// of its request still has to be honored.
it("ignores unknown element names in either list", () => {
expect(
hidden({ enable: true, excludedElements: ["sidebar", "from_the_future"] })
).toEqual(["sidebar"]);
expect(
hidden({
enable: true,
includedElements: [...KIOSK_ELEMENTS, "from_the_future"],
})
).toEqual([]);
});
});
// The companion app and an ingress add-on can each ask for kiosk mode. Neither
// may undo or narrow what the other still asks to hide.
describe("combineKioskElementsHidden", () => {
it("hides nothing when no source hides anything", () => {
expect(combineKioskElementsHidden([])).toBe(NO_KIOSK_ELEMENTS_HIDDEN);
expect(combineKioskElementsHidden([new Set(), new Set()])).toBe(
NO_KIOSK_ELEMENTS_HIDDEN
);
});
it("keeps a lone source's set as is", () => {
const external: ReadonlySet<KioskElement> = new Set(["sidebar_button"]);
expect(combineKioskElementsHidden([external, new Set()])).toBe(external);
});
it("hides what any source hides", () => {
expect(
[
...combineKioskElementsHidden([
new Set<KioskElement>(["sidebar_button"]),
DEFAULT_KIOSK_ELEMENTS_HIDDEN,
]),
].sort()
).toEqual([...DEFAULT_KIOSK_ELEMENTS_HIDDEN].sort());
expect(
[
...combineKioskElementsHidden([
new Set<KioskElement>(["dashboard_tabs"]),
new Set<KioskElement>(["app_panel_header"]),
]),
].sort()
).toEqual(["app_panel_header", "dashboard_tabs"]);
});
});
@@ -19,6 +19,7 @@ import type {
EMIncomingMessageImprovDeviceSetupDone,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageKioskModeSet,
} from "../../src/external_app/external_messaging";
vi.mock("../../src/common/dom/fire_event", () => ({
@@ -285,4 +286,74 @@ describe("handleExternalMessage", () => {
});
expect(result).toBe(true);
});
it("handles kiosk_mode/set command with excluded_elements", () => {
const msg: EMIncomingMessageKioskModeSet = {
type: "command",
command: "kiosk_mode/set",
id: 13,
payload: { enable: true, excluded_elements: ["sidebar_button"] },
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireEvent).toHaveBeenCalledWith(window, "hass-kiosk-mode", {
enable: true,
excludedElements: ["sidebar_button"],
includedElements: undefined,
});
expect(fireMessage).toHaveBeenCalledWith({
id: 13,
type: "result",
success: true,
result: null,
});
expect(result).toBe(true);
});
it("handles kiosk_mode/set command with included_elements", () => {
const msg: EMIncomingMessageKioskModeSet = {
type: "command",
command: "kiosk_mode/set",
id: 14,
payload: { enable: true, included_elements: ["dashboard_tabs"] },
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireEvent).toHaveBeenCalledWith(window, "hass-kiosk-mode", {
enable: true,
excludedElements: undefined,
includedElements: ["dashboard_tabs"],
});
expect(fireMessage).toHaveBeenCalledWith({
id: 14,
type: "result",
success: true,
result: null,
});
expect(result).toBe(true);
});
it("rejects kiosk_mode/set command with both element lists", () => {
const msg: EMIncomingMessageKioskModeSet = {
type: "command",
command: "kiosk_mode/set",
id: 15,
payload: {
enable: true,
excluded_elements: ["sidebar"],
included_elements: ["dashboard_tabs"],
},
};
const result = handleExternalMessage(hassMainEl, msg);
expect(fireEvent).not.toHaveBeenCalled();
expect(fireMessage).toHaveBeenCalledTimes(1);
expect(fireMessage).toHaveBeenCalledWith({
id: 15,
type: "result",
success: false,
error: {
code: "invalid_format",
message: "Pass either excluded_elements or included_elements, not both",
},
});
expect(result).toBe(true);
});
});