diff --git a/README.md b/README.md index febc27fe..16682aa8 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,18 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t | Parameter | Description | | - | - | | `type` | Must be `custom:frigate-card-menu-submenu`. | -| `items` | A list of menu items. Each menu item in turn also follows the parameters the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element). Typical usage would set the `title` parameter to control the text displayed for the menu item, the `icon` parameter to control the icon displayed for the menu item and one or more actions (e.g. `tap_action`, `double_tap_action` or `hold_action`) to configure the action to take. Unlike the stock Icon Element, the `icon` parameter is optional for individual menu items; if unspecified no icon is displayed for that menu item.| +| `items` | A list of menu items, as described below. | + +##### Submenu Items + +| Parameter | Default | Description | +| - | - | - | +| `title` | | An optional title to display. | +| `icon` | | An optional item icon to display. | +| `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. | +| `state_color` | `true` | Whether or not the title and icon should be stylized based on state. | +| `style` | | Position and style the element using CSS. | +| `tap_action`, `double_tap_action` or `hold_action` | | Standard [Home Assistant action configuration](https://www.home-assistant.io/lovelace/actions). | See the [Configuring a Submenu example](#configuring-a-submenu-example). diff --git a/src/card.ts b/src/card.ts index f77c42a4..f1814bcc 100644 --- a/src/card.ts +++ b/src/card.ts @@ -9,7 +9,7 @@ import { } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { until } from 'lit/directives/until.js'; import { HomeAssistant, @@ -222,6 +222,16 @@ export class FrigateCard extends LitElement { }; } + /** + * Get the style of emphasized menu items. + * @returns A StyleInfo. + */ + protected _getEmphasizedStyle(): StyleInfo { + return { + color: 'var(--primary-color, white)', + }; + } + /** * Get a FrigateCard MenuButton given a set of parameters. * @param params Menu button parameters. @@ -234,7 +244,7 @@ export class FrigateCard extends LitElement { type: 'custom:frigate-card-menu-icon', title: params.title, icon: params.icon, - style: params.emphasize ? FrigateCardMenu.getEmphasizedStyle() : {}, + style: params.emphasize ? this._getEmphasizedStyle() : {}, tap_action: params.tap_action ? createFrigateCardCustomAction(params.tap_action) : undefined, diff --git a/src/common.ts b/src/common.ts index a87240aa..4c15471f 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,6 +1,8 @@ +import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; +import { HomeAssistant, stateIcon } from 'custom-card-helpers'; +import { StyleInfo } from 'lit/directives/style-map'; import { ZodSchema, z } from 'zod'; -import { MessageBase } from 'home-assistant-js-websocket'; -import { HomeAssistant } from 'custom-card-helpers'; + import { localize } from './localize/localize.js'; import { ActionType, @@ -11,6 +13,7 @@ import { Message, SignedPath, signedPathSchema, + StateParameters, } from './types.js'; const MEDIA_INFO_HEIGHT_CUTOFF = 50; @@ -264,8 +267,11 @@ export function isValidMediaShowInfo(info: MediaShowInfo): boolean { * @returns A FrigateCardCustomAction or null if it cannot be converted. */ export function convertActionToFrigateCardCustomAction( - action: ActionType, + action: ActionType | null, ): FrigateCardCustomAction | null { + if (!action) { + return null; + } // Parse a custom event as other things could generate ll-custom events that // are not related to Frigate Card. const parseResult = frigateCardCustomActionSchema.safeParse(action); @@ -310,3 +316,69 @@ export function getActionConfigGivenAction( } return null; } + +/** + * Calculate a style brightness from a hass state. + * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296 + * @param state The hass state object. + * @returns A CSS brightness string. + */ +function computeBrightnessFromState(state: HassEntity): string { + if (state.state === 'off' || !state.attributes.brightness) { + return ''; + } + const brightness = state.attributes.brightness; + return `brightness(${(brightness + 245) / 5}%)`; +} + +/** + * Calculate a style color from a hass state. + * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304 + * @param state The hass state object. + * @returns A CSS color string. + */ +function computeColorFromState(state: HassEntity): string { + if (state.state === 'off') { + return ''; + } + return state.attributes.rgb_color + ? `rgb(${state.attributes.rgb_color.join(',')})` + : ''; +} + +/** + * Get the style of emphasized menu items. + * @returns A StyleInfo. + */ +function computeStyle(state: HassEntity): StyleInfo { + return { + color: computeColorFromState(state), + filter: computeBrightnessFromState(state), + }; +} + +/** + * Use Home Assistant state to refresh state parameters for an item to be rendered. + * @param hass Home Assistant object. + * @param params A StateParameters object to modify in place. + * @returns A StateParameters object updated based on HASS state. + */ +export function refreshDynamicStateParameters( + hass: HomeAssistant, + params: StateParameters, +): StateParameters { + if (!params.entity) { + return params; + } + const state = hass.states[params.entity]; + if ( + !!state && + !!params.state_color && + ['on', 'active', 'home'].includes(state.state) + ) { + params.style = { ...computeStyle(state), ...params.style }; + } + params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); + params.icon = params.icon ?? stateIcon(state); + return params; +} diff --git a/src/components/menu.ts b/src/components/menu.ts index d3398512..787ea5d6 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,16 +1,14 @@ -import { HassEntity } from 'home-assistant-js-websocket'; -import { HomeAssistant, handleAction, hasAction, stateIcon } from 'custom-card-helpers'; +import { HomeAssistant, handleAction, hasAction } from 'custom-card-helpers'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS, - PropertyValues, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; @@ -21,11 +19,12 @@ import type { ExtendedHomeAssistant, MenuButton, MenuConfig, + StateParameters, } from '../types.js'; import { convertActionToFrigateCardCustomAction, getActionConfigGivenAction, - shouldUpdateBasedOnHass, + refreshDynamicStateParameters, } from '../common.js'; import menuStyle from '../scss/menu.scss'; @@ -86,7 +85,7 @@ export class FrigateCardMenu extends LitElement { const interaction: string = ev.detail.action; const action = getActionConfigGivenAction(interaction, config); - if (!config || !action || !interaction) { + if (!config || !interaction) { return; } @@ -109,39 +108,6 @@ export class FrigateCardMenu extends LitElement { handleAction(this, this.hass as HomeAssistant, config, interaction); } - /** - * Determine whether the menu should be updated. - * @param changedProps The changed properties. - * @returns `true` if the menu should be updated, otherwise `false`. - */ - protected shouldUpdate(changedProps: PropertyValues): boolean { - const oldHass = changedProps.get('hass') as HomeAssistant | undefined; - - if (changedProps.size > 1 || !oldHass) { - return true; - } - - // Extract the entities the menu rendering depends on (if any). - const entities: string[] = []; - for (let i = 0; i < this.buttons.length; i++) { - const button = this.buttons[i]; - if (button.type == 'custom:frigate-card-menu-state-icon') { - entities.push(button.entity); - } - } - return shouldUpdateBasedOnHass(this.hass, oldHass, entities); - } - - /** - * Get the style of emphasized menu items. - * @returns A StyleInfo. - */ - public static getEmphasizedStyle(): StyleInfo { - return { - color: 'var(--primary-color, white)', - }; - } - /** * Render a button. * @param button The button configuration to render. @@ -150,19 +116,17 @@ export class FrigateCardMenu extends LitElement { protected _renderButton(button: MenuButton): TemplateResult | void { if (button.type == 'custom:frigate-card-menu-submenu') { return html` `; } - let state: HassEntity | null = null; - let title = button.title; - let icon = button.icon; - let style = button.style || {}; + let stateParameters: StateParameters = {...button}; - if (icon == FRIGATE_BUTTON_MENU_ICON) { - icon = + if (stateParameters.icon == FRIGATE_BUTTON_MENU_ICON) { + stateParameters.icon = this._menuConfig?.mode.startsWith('hidden-') && !this.expand ? 'mdi:alpha-f-box-outline' : 'mdi:alpha-f-box'; @@ -172,16 +136,7 @@ export class FrigateCardMenu extends LitElement { if (!this.hass) { return; } - state = this.hass.states[button.entity]; - if ( - !!state && - button.state_color && - ['on', 'active', 'home'].includes(state.state) - ) { - style = { ...style, ...FrigateCardMenu.getEmphasizedStyle() }; - } - title = title ?? (state?.attributes?.friendly_name || button.entity); - icon = icon ?? stateIcon(state); + stateParameters = refreshDynamicStateParameters(this.hass, stateParameters); } const hasHold = hasAction(button.hold_action); @@ -197,17 +152,17 @@ export class FrigateCardMenu extends LitElement { // - title (replaced with .label) return html` this._actionHandler(ev, button)} .actionHandler=${actionHandler({ hasHold: hasHold, hasDoubleClick: hasDoubleClick, })} > - + `; } diff --git a/src/components/submenu.ts b/src/components/submenu.ts index 58293024..c9b9f60e 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -1,22 +1,60 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; +import { hasAction, HomeAssistant } from 'custom-card-helpers'; +import { styleMap } from 'lit/directives/style-map'; +import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js'; import { actionHandler } from '../action-handler-directive.js'; -import { MenuSubmenu } from '../types.js'; +import { refreshDynamicStateParameters } from '../common.js'; import submenuStyle from '../scss/submenu.scss'; -import { hasAction } from 'custom-card-helpers'; -import { styleMap } from 'lit/directives/style-map'; @customElement('frigate-card-submenu') export class FrigateCardSubmenu extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant & ExtendedHomeAssistant; + @property({ attribute: false }) public submenu?: MenuSubmenu; + protected _renderItem(item: MenuSubmenuItem): TemplateResult | void { + if (!this.hass) { + return; + } + const stateParameters = refreshDynamicStateParameters(this.hass, {...item}); + + return html` + { + // Attach the action config so ascendants have access to it. + ev.detail.config = item; + }} + .actionHandler=${actionHandler({ + hasHold: hasAction(item.hold_action), + hasDoubleClick: hasAction(item.double_tap_action), + })} + > + ${stateParameters.title || ''} + ${stateParameters.icon + ? html` + ` + : ``} + + `; + } + protected render(): TemplateResult { if (!this.submenu) { return html``; } + return html` - ${this.submenu.items.map( - (item) => html` - { - // Attach the action config so ascendants have access to it. - ev.detail.config = item; - }} - .actionHandler=${actionHandler({ - hasHold: hasAction(item.hold_action), - hasDoubleClick: hasAction(item.double_tap_action), - })} - > - ${item.title || ''} - ${item.icon - ? html` - ` - : ``} - - `, - )} + ${this.submenu.items.map(this._renderItem.bind(this))} `; } diff --git a/src/types.ts b/src/types.ts index 6355ec65..622aa007 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import { StyleInfo } from 'lit/directives/style-map'; import { CallServiceActionConfig, CustomActionConfig, @@ -269,11 +270,19 @@ export const menuStateIconSchema = stateIconSchema.merge( ); export type MenuStateIcon = z.infer; +const menuSubmenuItemSchema = elementsBaseSchema.merge( + z.object({ + entity: z.string().optional(), + icon: z.string().optional(), + state_color: z.boolean().default(true), + }), +); +export type MenuSubmenuItem = z.infer; + export const menuSubmenuSchema = iconSchema.merge( z.object({ type: z.literal('custom:frigate-card-menu-submenu'), - // Menu items don't strictly require their own icon. - items: iconSchema.omit({ type: true }).partial({ icon: true }).array(), + items: menuSubmenuItemSchema.array(), }), ); export type MenuSubmenu = z.infer; @@ -644,6 +653,14 @@ export interface Message { icon?: string; } +export interface StateParameters { + entity?: string; + icon?: string; + title?: string | null; + state_color?: boolean; + style?: StyleInfo; +} + /** * Home Assistant API types. */