Merge pull request #247 from dermotduffy/state-in-submenus
Add support for submenus to reflect HA state
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
+12
-2
@@ -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,
|
||||
|
||||
+75
-3
@@ -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;
|
||||
}
|
||||
|
||||
+15
-60
@@ -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` <frigate-card-submenu
|
||||
.hass=${this.hass}
|
||||
.submenu=${button}
|
||||
@action=${this._actionHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-submenu>`;
|
||||
}
|
||||
|
||||
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` <ha-icon-button
|
||||
class="${classMap(classes)}"
|
||||
style="${styleMap(style)}"
|
||||
icon=${icon || 'mdi:gesture-tap-button'}
|
||||
.label=${title || ''}
|
||||
title=${title || ''}
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
icon=${stateParameters.icon || 'mdi:gesture-tap-button'}
|
||||
.label=${stateParameters.title || ''}
|
||||
title=${stateParameters.title || ''}
|
||||
@action=${(ev) => this._actionHandler(ev, button)}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasHold,
|
||||
hasDoubleClick: hasDoubleClick,
|
||||
})}
|
||||
>
|
||||
<ha-icon icon="${icon || 'mdi:gesture-tap-button'}"></ha-icon>
|
||||
<ha-icon icon="${stateParameters.icon || 'mdi:gesture-tap-button'}"></ha-icon>
|
||||
</ha-icon-button>`;
|
||||
}
|
||||
|
||||
|
||||
+42
-30
@@ -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`
|
||||
<mwc-list-item
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
graphic="icon"
|
||||
aria-label="${stateParameters.title || ''}"
|
||||
@action=${(ev) => {
|
||||
// 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` <ha-icon
|
||||
style="${styleMap(stateParameters.style || {})}"
|
||||
slot="graphic"
|
||||
icon="${stateParameters.icon}"
|
||||
>
|
||||
</ha-icon>`
|
||||
: ``}
|
||||
</mwc-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.submenu) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-button-menu corner="BOTTOM_LEFT">
|
||||
<ha-icon-button
|
||||
@@ -31,33 +69,7 @@ export class FrigateCardSubmenu extends LitElement {
|
||||
>
|
||||
<ha-icon icon="${this.submenu.icon}"></ha-icon>
|
||||
</ha-icon-button>
|
||||
${this.submenu.items.map(
|
||||
(item) => html`
|
||||
<mwc-list-item
|
||||
style="${styleMap(item.style || {})}"
|
||||
graphic="icon"
|
||||
aria-label="${item.title || ''}"
|
||||
@action=${(ev) => {
|
||||
// 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` <ha-icon
|
||||
style="${styleMap(item.style || {})}"
|
||||
slot="graphic"
|
||||
icon="${item.icon}"
|
||||
>
|
||||
</ha-icon>`
|
||||
: ``}
|
||||
</mwc-list-item>
|
||||
`,
|
||||
)}
|
||||
${this.submenu.items.map(this._renderItem.bind(this))}
|
||||
</ha-button-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
+19
-2
@@ -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<typeof menuStateIconSchema>;
|
||||
|
||||
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<typeof menuSubmenuItemSchema>;
|
||||
|
||||
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<typeof menuSubmenuSchema>;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user