diff --git a/README.md b/README.md index 262987b9..e2d564ee 100644 --- a/README.md +++ b/README.md @@ -222,18 +222,18 @@ This card supports all [Picture Elements](https://www.home-assistant.io/lovelace | Element name | Description | | ------------- | --------------------------------------------- | -| `menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).| -| `menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).| +| `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).| +| `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).| See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available. -#### Elements Example +#### Elements Examples Add an icon that represents the state of the `light.office_main_lights` entity, that shows more information on single click (the default action) and toggles the light on double click. ```yaml elements: - - type: menu-state-icon + - type: custom:frigate-card-menu-state-icon entity: light.office_main_lights double_tap_action: action: toggle @@ -242,7 +242,7 @@ elements: Add an icon that navigates the brower to the releases page for this card: ```yaml - - type: menu-icon + - type: custom:frigate-card-menu-icon icon: mdi:book tap_action: action: url @@ -263,6 +263,20 @@ Add a state badge showing the temperature but hide the label text: Picture elements temperature example +You can also have icons conditionally added to the menu, such as only showing a menu icon if a light is on: + +```yaml + - type: conditional + conditions: + - entity: light.kitchen + state: 'on' + elements: + - type: custom:frigate-card-menu-state-icon + entity: light.kitchen + tap_action: + action: toggle +``` + ## Views diff --git a/src/card.ts b/src/card.ts index 29d2ba04..6c2b3b96 100644 --- a/src/card.ts +++ b/src/card.ts @@ -19,7 +19,11 @@ import { } from 'custom-card-helpers'; import screenfull from 'screenfull'; -import { entitySchema, frigateCardConfigSchema, Message } from './types'; +import { + entitySchema, + frigateCardConfigSchema, + Message, +} from './types'; import type { BrowseMediaQueryParameters, Entity, @@ -204,14 +208,6 @@ export class FrigateCard extends LitElement { icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', }); } - - const elements = this.config.elements || []; - for (let i = 0; this._hass && i < elements.length; i++) { - const element = elements[i]; - if (element.type == 'menu-icon' || element.type == 'menu-state-icon') { - buttons.push(element); - } - } return buttons; } @@ -573,7 +569,7 @@ export class FrigateCard extends LitElement { this._frigateCameraName = await this._getFrigateCameraName(); } const mediaQueryParameters = this._getBrowseMediaQueryParameters(); - if (!this._frigateCameraName || !mediaQueryParameters) { + if (!this._hass || !this._frigateCameraName || !mediaQueryParameters) { return this._setMessageAndUpdate({ message: localize('error.no_frigate_camera_name'), type: 'error', @@ -629,6 +625,13 @@ export class FrigateCard extends LitElement { { + this._menu.addButton(e.detail); + }} + @frigate-card:menu-remove=${(e) => { + this._menu.removeButton(e.detail); + }} > diff --git a/src/components/elements.ts b/src/components/elements.ts index 15e5d573..6b5f4a20 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -2,91 +2,136 @@ import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property } from 'lit/decorators'; -import { ExtendedHomeAssistant, PictureElement, PictureElements } from '../types'; +import { ExtendedHomeAssistant, MenuButton, MenuIcon, MenuStateIcon, menuStateIconSchema, PictureElements } from '../types'; +import { menuIconSchema } from '../types' +import { dispatchErrorMessageEvent, dispatchEvent, getParseErrorKeys } from '../common'; import elementsStyle from '../scss/elements.scss'; +import { localize } from '../localize/localize'; +import { z } from 'zod'; + +/* A note on picture element rendering: + * + * To avoid needing to deal with the rendering of all the picture elements + * ourselves, instead the card relies on a stock conditional element (with no + * conditions) to render elements (this._root). This has a few advantages: + * + * - Does not depend on (much of!) an internal API -- conditional picture + * elements are unlikely to go away or change. + * - Forces usage of elements that HA understands. If the rendering is done + * directly, it is (ask me how I know!) very tempting to render things in such + * a way that a nested conditional element would not be able to render, i.e. + * the custom rendering logic would only apply at the first level. + */ + +/* A note on custom elements: + * + * The native HA support for custom elements is used for the menu-icon and + * menu-state-icon elements. This ensures multi-nested conditionals will work + * correctly. These custom elements 'render' by firing events that are caught by + * the card to call for inclusion/exclusion of the menu icon in question. + * + * One major complexity here is that the top element + * will not necessarily know when a menu icon is no longer rendered because of a + * conditional that no-longer evaluates to true. As such, it cannot know when to + * signal for the menu icon removal. Furthermore, the menu icon element itself + * will only know it's been removed _after_ it's been disconnected from the DOM, + * so normal event propagation at that point will not work. Instead, we must + * catch the menu icon _addition_ and register the eventhandler for the removal + * directly on the child (which will have no parent at time of calling). That + * then triggers to re-dispatch a removal event for + * upper layers to handle correctly. + */ + @customElement('frigate-card-elements') export class FrigateCardElements extends LitElement { @property({ attribute: false }) - protected _pictureElements: PictureElements; + protected pictureElements: PictureElements; protected _hass!: HomeAssistant & ExtendedHomeAssistant; - protected _elements: HTMLElement[] = []; + protected _root: HTMLElement | null = null; set hass(hass: HomeAssistant & ExtendedHomeAssistant) { - for (let i = 0; hass && i < this._elements.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this._elements[i] as any).hass = hass; + if (this._root) { + (this._root as any).hass = hass; } this._hass = hass; } - set pictureElements(pictureElements: PictureElements) { - if (this._elements.length > 0) { - this._elements.forEach((el: HTMLElement) => { - if (el.parentElement) { - el.parentElement.removeChild(el); - } - }); - this._elements = []; - } - if (!pictureElements) { + protected _menuRemoveHandler(ev: Event): void { + // Re-dispatch event from this element (instead of the disconnected one, as + // there is no parent of the disconnected element). + dispatchEvent(this, 'menu-remove', (ev as CustomEvent).detail); + } + + protected _menuAddHandler(ev: Event): void { + ev = ev as CustomEvent; + const path = ev.composedPath() + if (!path.length) { return; } - for (let i = 0; i < pictureElements.length && pictureElements[i]; i++) { - const element = this._createPictureElement(pictureElements[i]); - if (element) { - this._elements.push(element); - } + + // See 'A note on custom elements' above to explain what's going on here. + + // Ensure listener is only attached 1 time by removing it first. + path[0].removeEventListener( + 'frigate-card:menu-remove', + this._menuRemoveHandler.bind(this)); + + path[0].addEventListener( + 'frigate-card:menu-remove', + this._menuRemoveHandler.bind(this)); + } + + connectedCallback(): void { + super.connectedCallback(); + + // Catch icons being added to the menu (so their removal can be subsequently + // handled). + this.addEventListener( + 'frigate-card:menu-add', + this._menuAddHandler, + ); + + if (!this._root) { + this._createRoot(); } } - @property({ attribute: false }) - protected _createPictureElement(pictureElement: PictureElement): HTMLElement | null { - let customElementName: string | null = null; - - switch (pictureElement.type) { - case 'state-badge': - case 'state-icon': - case 'state-label': - case 'service-button': - case 'icon': - case 'image': - case 'conditional': - customElementName = `hui-${pictureElement.type}-element`; - break; - } - - if (!customElementName) { - return null; - } + disconnectedCallback(): void { + this.removeEventListener( + 'frigate-card:menu-add', + this._menuAddHandler, + ); + super.disconnectedCallback(); + } + protected _createRoot(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const elementConstructor = customElements.get(customElementName) as any; + const elementConstructor = customElements.get('hui-conditional-element') as any; if (!elementConstructor) { - return null; + return; } const element = new elementConstructor(); element.hass = this._hass; + const config = { + type: 'conditional', + conditions: [], + elements: this.pictureElements, + } try { - element.setConfig(pictureElement); + element.setConfig(config); } catch (e) { console.error(e, (e as Error).stack); - return null; + return; } - element.classList.add('element'); - - const targetStyle = pictureElement.style || {}; - Object.keys(targetStyle).forEach((prop) => { - element.style.setProperty(prop, targetStyle[prop]); - }); - return element; + this._root = element; } protected render(): TemplateResult { - return html`${this._elements.map((element) => element)}`; + return html`${this._root || ''}`; } static get styles(): CSSResultGroup { @@ -94,10 +139,31 @@ export class FrigateCardElements extends LitElement { } } -export function renderFrigateCardElements( - hass: HomeAssistant & ExtendedHomeAssistant, - pictureElements: PictureElements, -): TemplateResult { - return html` - `; +export class FrigateCardElementsBaseMenuIcon extends LitElement { + @property({ attribute: false }) + protected _config: T | null = null; + + public setConfig(config: T): void { + this._config = config; + } + + connectedCallback(): void { + super.connectedCallback() + if (this._config) { + dispatchEvent(this, 'menu-add', this._config); + } + } + + disconnectedCallback(): void { + if (this._config) { + dispatchEvent(this, 'menu-remove', this._config); + } + super.disconnectedCallback() + } } + +@customElement('frigate-card-menu-icon') +export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon {} + +@customElement('frigate-card-menu-state-icon') +export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {} \ No newline at end of file diff --git a/src/components/menu.ts b/src/components/menu.ts index 9c57cbe5..e9a764d9 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -33,6 +33,16 @@ export class FrigateCardMenu extends LitElement { @property({ attribute: false }) public buttons: MenuButton[] = []; + public addButton(button: MenuButton): void { + if (!this.buttons.includes(button)) { + this.buttons = [...this.buttons, button]; + } + } + + public removeButton(target: MenuButton): void { + this.buttons = this.buttons.filter(button => button != target); + } + // Call the callback. protected _callAction(ev: CustomEvent, button: MenuButton): void { if (this.menuMode.startsWith('hidden-')) { @@ -53,7 +63,7 @@ export class FrigateCardMenu extends LitElement { protected shouldUpdate(changedProps: PropertyValues): boolean { const oldHass = changedProps.get('hass') as HomeAssistant | undefined; - if (!oldHass) { + if (changedProps.size > 1 || !oldHass) { return true; } @@ -61,7 +71,7 @@ export class FrigateCardMenu extends LitElement { const entities: string[] = [] for (let i = 0; i < this.buttons.length; i++) { const button = this.buttons[i]; - if (button.type == 'menu-state-icon') { + if (button.type == 'custom:frigate-card-menu-state-icon') { entities.push(button.entity); } } @@ -76,7 +86,7 @@ export class FrigateCardMenu extends LitElement { let icon = button.icon; const style = ('style' in button ? button.style : {}) || {}; - if (button.type === 'menu-state-icon') { + if (button.type === 'custom:frigate-card-menu-state-icon') { state = this.hass.states[button.entity]; emphasize = !!state && button.state_color && ['on', 'active', 'home'].includes(state.state); diff --git a/src/scss/elements.scss b/src/scss/elements.scss index f363ee4d..239c854c 100644 --- a/src/scss/elements.scss +++ b/src/scss/elements.scss @@ -1,4 +1,12 @@ .element { position: absolute; transform: translate(-50%, -50%); +} + +// Errors encountered by HA (not the card) during parsing of the configuration +// (e.g. custom picture element that does not exist). +hui-error-card.element { + inset: 0px; + background-color: var(--secondary-background-color, black); + transform: unset; } \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 0ea92f8c..5e285c92 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -3,6 +3,13 @@ :host { overflow: auto; + -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ + scrollbar-width: none; /* Hide scrollbar: Firefox */ +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; } .frigate-card-gallery { diff --git a/src/types.ts b/src/types.ts index 35bcf67b..dc983c3d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,10 @@ const elementsBaseSchema = z.object({ /** * Picture Element Types + * + * All picture element types are validated (not just the Frigate card custom + * ones) as a convenience to present the user with a consistent error display + * up-front regardless of where they made their error. */ // https://www.home-assistant.io/lovelace/picture-elements/#state-badge @@ -188,19 +192,27 @@ const conditionalSchema = elementsBaseSchema.merge( elements: z.lazy(() => pictureElementsSchema), })); +// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements +const customSchema = z.object({ + // Insist that Frigate card custom elements are handled by other schemas. + type: z.string().regex(/^custom:(?!frigate-card).+/), + }).passthrough(); + /** * Menu Element Types */ -const menuIconSchema = iconSchema.merge( +export const menuIconSchema = iconSchema.merge( z.object({ - type: z.literal('menu-icon'), + type: z.literal('custom:frigate-card-menu-icon'), })); +export type MenuIcon = z.infer; -const menuStateIconSchema = stateIconSchema.merge( +export const menuStateIconSchema = stateIconSchema.merge( z.object({ - type: z.literal('menu-state-icon'), + type: z.literal('custom:frigate-card-menu-state-icon'), })); +export type MenuStateIcon = z.infer; // Schema for card (non-user configured) menu icons. const internalMenuIconSchema = z @@ -231,6 +243,7 @@ const pictureElementSchema = z.union([ iconSchema, imageSchema, conditionalSchema, + customSchema, ]); export type PictureElement = z.infer;