mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-15 13:31:27 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c51007c2 | ||
|
|
050edb3f01 | ||
|
|
f8b6605915 | ||
|
|
22d48fbfdd |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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__,
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user