import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../config/types.js'; import submenuStyle from '../scss/submenu.scss'; import { hasAction, stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { getEntityTitle, isHassDifferent } from '../utils/ha'; import { getEntityStateTranslation } from '../utils/ha/entity-state-translation.js'; import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js'; import './icon.js'; import { Icon } from '../types.js'; interface ExtendedMenuSubmenu extends MenuSubmenu { // An internal version of a submenu that allows entity-based submenus (for // AdvancedCameraCardSubmenuSelect). icon: string | Icon; } @customElement('advanced-camera-card-submenu') export class AdvancedCameraCardSubmenu extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public submenu?: ExtendedMenuSubmenu; protected _renderItem(item: MenuSubmenuItem): TemplateResult | void { if (!this.hass) { return; } const title = item.title ?? getEntityTitle(this.hass, item.entity); const style = styleMap(item.style || {}); 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), })} > ${title ?? ''} ${item.subtitle ? html`${item.subtitle}` : ''} `; } protected render(): TemplateResult { if (!this.submenu) { return html``; } const items = this.submenu.items as MenuSubmenuItem[]; const style = styleMap(this.submenu.style || {}); return html` ev.stopPropagation() } @click=${(ev) => stopEventFromActivatingCardWideActions(ev)} > trigger slot to open/close the menu. Further // propagation is forbidden by the @click handler on // . allowPropagation: true, hasHold: hasAction(this.submenu.hold_action), hasDoubleClick: hasAction(this.submenu.double_tap_action), })} > ${items.map(this._renderItem.bind(this))} `; } static get styles(): CSSResultGroup { return unsafeCSS(submenuStyle); } } @customElement('advanced-camera-card-submenu-select') export class AdvancedCameraCardSubmenuSelect extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public submenuSelect?: MenuSubmenuSelect; @property({ attribute: false }) public entityRegistryManager?: EntityRegistryManager; @state() protected _optionTitles?: Record; protected _generatedSubmenu?: MenuSubmenu; /** * Called to determine if the update should proceed. * @param changedProps * @returns `true` if the update should proceed, `false` otherwise. */ protected shouldUpdate(changedProps: PropertyValues): boolean { // No need to update the submenu unless the select entity has changed. const oldHass = changedProps.get('hass') as HomeAssistant | undefined; return ( !changedProps.has('hass') || !oldHass || !this.submenuSelect || isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity]) ); } protected async _refreshOptionTitles(): Promise { if (!this.hass || !this.submenuSelect) { return; } const entityID = this.submenuSelect.entity; const stateObj = this.hass.states[entityID]; const options = stateObj?.attributes?.options; const entity = (await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null; const optionTitles = {}; for (const option of options) { const title = getEntityStateTranslation(this.hass, entityID, { ...(entity && { entity: entity }), state: option, }); if (title) { optionTitles[option] = title; } } // This will cause a re-render with the updated title if it is // different. this._optionTitles = optionTitles; } /** * Called when the render function will be called. */ protected willUpdate(): void { if (!this.submenuSelect || !this.hass) { return; } if (!this._optionTitles) { this._refreshOptionTitles(); } const entityID = this.submenuSelect.entity; const stateObj = this.hass.states[entityID]; const options = stateObj?.attributes?.options; if (!stateObj || !options) { return; } const title = getEntityTitle(this.hass, entityID); const submenu: MenuSubmenu = { ...(title && { title }), // Override it with anything explicitly set in the submenuSelect. ...this.submenuSelect, icon: { icon: this.submenuSelect.icon, entity: entityID, fallback: 'mdi:format-list-bulleted', }, type: 'custom:advanced-camera-card-menu-submenu', items: [], }; // For cleanliness remove the options parameter which is unused by the // submenu rendering itself (above). It is only in this method to populate // the items correctly (below). delete submenu['options']; const items = submenu.items as MenuSubmenuItem[]; for (const option of options) { const title = this._optionTitles?.[option] ?? option; items.push({ state_color: true, selected: stateObj.state === option, enabled: true, title: title || option, ...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && { tap_action: { action: 'perform-action', perform_action: entityID.startsWith('select.') ? 'select.select_option' : 'input_select.select_option', target: { entity_id: entityID, }, data: { option: option, }, }, }), // Apply overrides the user may have specified for a given option. ...(this.submenuSelect.options && this.submenuSelect.options[option]), }); } this._generatedSubmenu = submenu; } protected render(): TemplateResult { return html` `; } } declare global { interface HTMLElementTagNameMap { 'advanced-camera-card-submenu': AdvancedCameraCardSubmenu; 'advanced-camera-card-submenu-select': AdvancedCameraCardSubmenuSelect; } }