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:
+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;