diff --git a/docs/configuration/live.md b/docs/configuration/live.md index b726186a..0e97c7c3 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -71,7 +71,7 @@ live: | Option | Default | Description | | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `hide_home` | `false` | When `true` the Home button of the control is hidden | +| `hide_home` | `false` | When `true` the Home and Presets buttons of the control are hidden | | `hide_pan_tilt` | `false` | When `true` the Pan & Tilt buttons of the control is hidden | | `hide_zoom` | `false` | When `true` the Zoom button of the control is hidden | | `mode` | `auto` | If `on` or `off`, by default will always or never show PTZ controls respectively, if `auto` will show PTZ controls only if the camera supports real PTZ. | diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index c14d65c4..f2f4297d 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -1,7 +1,7 @@ -import { HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { LitElement } from 'lit'; import { orderBy } from 'lodash-es'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; +import { SubmenuInteraction } from '../components/submenu/types.js'; import { MENU_PRIORITY_MAX, type ActionType, @@ -9,6 +9,7 @@ import { type MenuConfig, type MenuItem, } from '../config/types.js'; +import { Interaction } from '../types.js'; import { convertActionToCardCustomAction, getActionConfigGivenAction, @@ -86,21 +87,16 @@ export class MenuController { this.setExpanded(!this._expanded); } - public actionHandler( - ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>, - config?: ActionsConfig, + public handleAction( + ev: CustomEvent>, + buttonConfig?: ActionsConfig, ): void { // These interactions should only be handled by the menu, as nothing // upstream has the user-provided configuration. ev.stopPropagation(); - // If the event itself contains a configuration then use that. This is - // useful in cases where the registration of the event handler does not have - // access to the actual desired configuration (e.g. action events generated - // by a submenu). - if (ev.detail.config) { - config = ev.detail.config; - } + // If the action is from a submenu, use the attached action config. + const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null; if (!config) { return; } diff --git a/src/components-lib/ptz/ptz-controller.ts b/src/components-lib/ptz/ptz-controller.ts index 52f1b0bd..770b06c1 100644 --- a/src/components-lib/ptz/ptz-controller.ts +++ b/src/components-lib/ptz/ptz-controller.ts @@ -1,10 +1,12 @@ -import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { CameraManager } from '../../camera-manager/manager'; import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request'; import { PTZAction } from '../../config/ptz'; import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types'; +import { Interaction } from '../../types'; import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action'; -import { PTZActionNameToMultiAction, PTZActionPresence } from './types'; +import { SubmenuInteraction } from '../../components/submenu/types'; +import { PTZControllerActions } from './types'; export class PTZController { private _host: HTMLElement; @@ -47,9 +49,11 @@ export class PTZController { } public handleAction( - ev: HASSDomEvent<{ action: string }>, - config?: ActionsConfig | null, + ev: CustomEvent>, + buttonConfig?: ActionsConfig | null, ): void { + const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null; + // Nothing else has the configuration for this action, so don't let it // propagate further. ev.stopPropagation(); @@ -64,34 +68,6 @@ export class PTZController { } } - public hasUsefulAction(): PTZActionPresence { - const allUsefulActions = { - pt: true, - z: true, - home: true, - }; - if (!this._cameraID) { - // Will use digital PTZ. - return allUsefulActions; - } - const capabilities = this._cameraManager?.getCameraCapabilities(this._cameraID); - if (!capabilities || !capabilities.hasPTZCapability()) { - // Will use digital PTZ. - return allUsefulActions; - } - - const ptzCapabilities = capabilities.getPTZCapabilities(); - return { - pt: - !!ptzCapabilities?.up || - !!ptzCapabilities?.down || - !!ptzCapabilities?.left || - !!ptzCapabilities?.right, - z: !!ptzCapabilities?.zoomIn || !!ptzCapabilities?.zoomOut, - home: !!ptzCapabilities?.presets?.length, - }; - } - public shouldDisplay(): boolean { return this._forceVisibility !== undefined ? this._forceVisibility @@ -103,8 +79,15 @@ export class PTZController { : this._config?.mode === 'on'; } - public getPTZActions(): PTZActionNameToMultiAction { - const getDefaultActions = (options?: { + public getPTZActions(): PTZControllerActions { + const cameraCapabilities = this._cameraID + ? this._cameraManager?.getCameraCapabilities(this._cameraID) + : null; + const hasRealPTZCapability = + cameraCapabilities && cameraCapabilities.hasPTZCapability(); + const ptzCapabilities = cameraCapabilities?.getPTZCapabilities(); + + const getContinuousActions = (options?: { ptzAction?: PTZAction; preset?: string; }): Actions => ({ @@ -120,28 +103,61 @@ export class PTZController { }), }); - const actions: PTZActionNameToMultiAction = {}; - actions.up = getDefaultActions({ - ptzAction: 'up', + const getDiscreteAction = (options?: { + ptzAction?: PTZAction; + preset?: string; + }): Actions => ({ + tap_action: createPTZMultiAction({ + ptzAction: options?.ptzAction, + ptzPreset: options?.preset, + }), }); - actions.down = getDefaultActions({ - ptzAction: 'down', - }); - actions.left = getDefaultActions({ - ptzAction: 'left', - }); - actions.right = getDefaultActions({ - ptzAction: 'right', - }); - actions.zoom_in = getDefaultActions({ - ptzAction: 'zoom_in', - }); - actions.zoom_out = getDefaultActions({ - ptzAction: 'zoom_out', - }); - actions.home = { - tap_action: createPTZMultiAction(), - }; + + const actions: PTZControllerActions = {}; + if (!hasRealPTZCapability || ptzCapabilities?.up) { + actions.up = getContinuousActions({ + ptzAction: 'up', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.down) { + actions.down = getContinuousActions({ + ptzAction: 'down', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.left) { + actions.left = getContinuousActions({ + ptzAction: 'left', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.right) { + actions.right = getContinuousActions({ + ptzAction: 'right', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.zoomIn) { + actions.zoom_in = getContinuousActions({ + ptzAction: 'zoom_in', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.zoomOut) { + actions.zoom_out = getContinuousActions({ + ptzAction: 'zoom_out', + }); + } + if (!hasRealPTZCapability || ptzCapabilities?.presets?.length) { + actions.home = getDiscreteAction(); + } + for (const preset of ptzCapabilities?.presets ?? []) { + actions.presets ??= []; + actions.presets.push({ + preset: preset, + actions: getDiscreteAction({ + preset: preset, + ptzAction: 'preset', + }), + }); + } + return actions; } } diff --git a/src/components-lib/ptz/types.ts b/src/components-lib/ptz/types.ts index 2c71cc60..b8bdd88d 100644 --- a/src/components-lib/ptz/types.ts +++ b/src/components-lib/ptz/types.ts @@ -10,12 +10,13 @@ declare module 'view' { } } -export type PTZActionNameToMultiAction = { - [K in PTZControlAction]?: Actions; -}; - -export interface PTZActionPresence { - pt: boolean; - z: boolean; - home: boolean; +interface PTZPresetAction { + preset: string; + actions: Actions; } + +export type PTZControllerActions = { + [K in PTZControlAction]?: Actions; +} & { + presets?: PTZPresetAction[]; +}; diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 4dc1d61e..3f83afda 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -399,6 +399,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { ${this._renderNextPrevious('right', neighbors)} this._controller.actionHandler(ev)} + @action=${(ev) => this._controller.handleAction(ev)} > - `; + `; } else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') { - return html` this._controller.actionHandler(ev)} + @action=${(ev) => this._controller.handleAction(ev)} > - `; + `; } const title = @@ -73,7 +74,7 @@ export class AdvancedCameraCardMenu extends LitElement { hasDoubleClick: hasAction(button.double_tap_action), })} .label=${title ?? ''} - @action=${(ev) => this._controller.actionHandler(ev, button)} + @action=${(ev) => this._controller.handleAction(ev, button)} > { const classes = { [name]: true, - disabled: !actions, + disabled: !options?.actions && !options?.renderWithoutAction, }; - return actions + return options?.actions || options?.renderWithoutAction ? html`) => - this._controller.handleAction(ev, actions)} + .actionHandler=${options.actions + ? actionHandler({ + hasHold: hasAction(options.actions?.hold_action), + hasDoubleClick: hasAction(options.actions?.double_tap_action), + }) + : undefined} + @action=${(ev: CustomEvent) => + options.actions && this._controller.handleAction(ev, options.actions)} >` : html``; }; + const presetSubmenuItems: SubmenuItem[] | null = this._actions?.presets?.length + ? this._actions.presets.map((preset) => ({ + title: prettifyTitle(preset.preset), + icon: 'mdi:cctv', + ...preset.actions, + hold_action: { + action: 'perform-action', + perform_action: 'camera.preset_recall', + }, + })) + : null; + const config = this._controller.getConfig(); return html`
- ${!config?.hide_pan_tilt && this._actionPresence?.pt + ${!config?.hide_pan_tilt && + (this._actions?.left || + this._actions?.right || + this._actions?.up || + this._actions?.down) ? html`
- ${renderIcon('right', 'mdi:arrow-right', this._actions.right)} - ${renderIcon('left', 'mdi:arrow-left', this._actions.left)} - ${renderIcon('up', 'mdi:arrow-up', this._actions.up)} - ${renderIcon('down', 'mdi:arrow-down', this._actions.down)} + ${renderIcon('right', 'mdi:arrow-right', { actions: this._actions?.right })} + ${renderIcon('left', 'mdi:arrow-left', { actions: this._actions?.left })} + ${renderIcon('up', 'mdi:arrow-up', { actions: this._actions?.up })} + ${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })}
` : ''} - ${!config?.hide_zoom && this._actionPresence?.z + ${!config?.hide_zoom && (this._actions?.zoom_in || this._actions?.zoom_out) ? html`
- ${renderIcon('zoom_in', 'mdi:plus', this._actions.zoom_in)} - ${renderIcon('zoom_out', 'mdi:minus', this._actions.zoom_out)} + ${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })} + ${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
` : html``} - ${!config?.hide_home && this._actionPresence?.home - ? html`
- ${renderIcon('home', 'mdi:home', this._actions.home)} + ${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length) + ? html`
+ ${renderIcon('home', 'mdi:home', { actions: this._actions?.home })} + ${presetSubmenuItems?.length + ? html`) => + this._controller.handleAction(ev)} + > + ${renderIcon( + 'presets', + config?.orientation === 'vertical' + ? 'mdi:dots-vertical' + : 'mdi:dots-horizontal', + { + renderWithoutAction: true, + }, + )} + ` + : ''}
` - : html``} + : ''}
`; } diff --git a/src/components/submenu.ts b/src/components/submenu.ts deleted file mode 100644 index 646b033d..00000000 --- a/src/components/submenu.ts +++ /dev/null @@ -1,273 +0,0 @@ -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; - } -} diff --git a/src/components/submenu/index.ts b/src/components/submenu/index.ts new file mode 100644 index 00000000..e624668c --- /dev/null +++ b/src/components/submenu/index.ts @@ -0,0 +1,95 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } 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 submenuStyle from '../../scss/submenu.scss'; +import { + hasAction, + stopEventFromActivatingCardWideActions, +} from '../../utils/action.js'; +import { getEntityTitle } from '../../utils/ha'; +import '../icon.js'; +import { SubmenuInteraction, SubmenuItem } from './types.js'; + +@customElement('advanced-camera-card-submenu') +export class AdvancedCameraCardSubmenu extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public items?: SubmenuItem[]; + + protected _renderItem(item: SubmenuItem): TemplateResult | void { + if (!this.hass) { + return; + } + + const title = item.title ?? getEntityTitle(this.hass, item.entity); + const style = styleMap(item.style || {}); + + return html` + ) => { + // Attach the item so ascendants have access to it. + ev.detail.item = item; + }} + .actionHandler=${actionHandler({ + allowPropagation: true, + hasHold: hasAction(item.hold_action), + hasDoubleClick: hasAction(item.double_tap_action), + })} + > + ${title ?? ''} + ${item.subtitle + ? html`${item.subtitle}` + : ''} + + + `; + } + + protected render(): TemplateResult { + return html` + ev.stopPropagation() + } + @click=${(ev: Event) => stopEventFromActivatingCardWideActions(ev)} + > + + ${this.items?.map(this._renderItem.bind(this))} + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(submenuStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-submenu': AdvancedCameraCardSubmenu; + } +} diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts new file mode 100644 index 00000000..6a6b1c01 --- /dev/null +++ b/src/components/submenu/select-button.ts @@ -0,0 +1,178 @@ +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 { styleMap } from 'lit/directives/style-map.js'; +import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../../config/types.js'; +import menuButtonStyle from '../../scss/menu-button.scss'; +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 './index.js'; + +@customElement('advanced-camera-card-submenu-select-button') +export class AdvancedCameraCardSubmenuSelectButton 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; + + 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; + } + + 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 { + const submenu = this._generatedSubmenu; + if (!submenu) { + return html``; + } + + const style = styleMap(submenu.style || {}); + return html` + + + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(menuButtonStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-submenu-select-button': AdvancedCameraCardSubmenuSelectButton; + } +} diff --git a/src/components/submenu/submenu-button.ts b/src/components/submenu/submenu-button.ts new file mode 100644 index 00000000..9970f80d --- /dev/null +++ b/src/components/submenu/submenu-button.ts @@ -0,0 +1,59 @@ +import { hasAction, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { actionHandler } from '../../action-handler-directive.js'; +import { MenuSubmenu } from '../../config/types.js'; +import menuButtonStyle from '../../scss/menu-button.scss'; +import '../icon.js'; +import './index.js'; + +@customElement('advanced-camera-card-submenu-button') +export class AdvancedCameraCardSubmenuButton extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public submenu?: MenuSubmenu; + + protected render(): TemplateResult { + if (!this.submenu) { + return html``; + } + + const style = styleMap(this.submenu.style || {}); + return html` + + 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), + })} + > + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(menuButtonStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-submenu-button': AdvancedCameraCardSubmenuButton; + } +} diff --git a/src/components/submenu/types.ts b/src/components/submenu/types.ts new file mode 100644 index 00000000..4f152c75 --- /dev/null +++ b/src/components/submenu/types.ts @@ -0,0 +1,19 @@ +import { Interaction } from '../../types'; + +export interface SubmenuItem { + title?: string; + subtitle?: string; + icon?: string; + entity?: string; + style?: Record; + enabled?: boolean; + selected?: boolean; + + hold_action?: unknown; + double_tap_action?: unknown; + [key: string]: unknown; +} + +export interface SubmenuInteraction extends Interaction { + item: SubmenuItem; +} diff --git a/src/components/surround-basic.ts b/src/components/surround-basic.ts index 4a0a8ddc..9a58d2fc 100644 --- a/src/components/surround-basic.ts +++ b/src/components/surround-basic.ts @@ -23,18 +23,12 @@ export class AdvancedCameraCardSurroundBasic extends LitElement { protected _refDrawerRight: Ref = createRef(); protected _boundDrawerHandler = this._drawerHandler.bind(this); - /** - * Component connected callback. - */ connectedCallback(): void { super.connectedCallback(); this.addEventListener('advanced-camera-card:drawer:open', this._boundDrawerHandler); this.addEventListener('advanced-camera-card:drawer:close', this._boundDrawerHandler); } - /** - * Component disconnected callback. - */ disconnectedCallback(): void { super.disconnectedCallback(); this.removeEventListener( @@ -77,9 +71,6 @@ export class AdvancedCameraCardSurroundBasic extends LitElement { `; } - /** - * Return compiled CSS styles. - */ static get styles(): CSSResultGroup { return unsafeCSS(surroundBasicStyle); } diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 44546a11..1122c478 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -396,6 +396,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { ${view ? html` diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 7c7e7793..1ce3c871 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -618,6 +618,7 @@ "down": "Avall", "home": "Casa", "left": "Esquerra", + "presets": "", "right": "Dreta", "up": "Amunt", "zoom_in": "Ampliar", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 6c3b3701..c313da24 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -317,7 +317,7 @@ "editor_label": "Live Controls", "ptz": { "editor_label": "PTZ", - "hide_home": "Hide home control", + "hide_home": "Hide home & preset controls", "hide_pan_tilt": "Hide pan & tilt control", "hide_zoom": "Hide zoom control", "mode": "Mode", @@ -618,6 +618,7 @@ "down": "Down", "home": "Home", "left": "Left", + "presets": "Presets", "right": "Right", "up": "Up", "zoom_in": "Zoom In", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index f35974dc..9b3fa6fb 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -618,6 +618,7 @@ "down": "Bas", "home": "Origine", "left": "Gauche", + "presets": "", "right": "Droite", "up": "Haut", "zoom_in": "Zoomer", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index de1b0372..e2fa1c65 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -618,6 +618,7 @@ "down": "Giù", "home": "Home", "left": "Sinistra", + "presets": "", "right": "Destra", "up": "Su", "zoom_in": "Ingrandire", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 957f1087..e9bb3fef 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -618,6 +618,7 @@ "down": "Baixo", "home": "Casa", "left": "Esquerda", + "presets": "", "right": "Direita", "up": "Cima", "zoom_in": "Aumentar Zoom", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f4995ede..7f72a1b6 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -618,6 +618,7 @@ "down": "Baixo", "home": "Origem", "left": "Esquerda", + "presets": "", "right": "Direira", "up": "Cima", "zoom_in": "Ampliar", diff --git a/src/scss/card.scss b/src/scss/card.scss index 8f70e057..b3fce863 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,4 +1,5 @@ @use './themes/base.scss'; +@import './z-index.scss'; :host { display: block; @@ -10,9 +11,8 @@ // this ensures the same experience across all browsers. background-color: var(--card-background-color); - // The primary border-radius used is the div.main. This is only useful for - // keeping the background-color within the radius. border-radius: var(--ha-card-border-radius, 4px); + overflow: auto; height: var(--advanced-camera-card-height); min-height: 100px; @@ -32,10 +32,18 @@ --advanced-camera-card-height: auto; } +// Without hovering over the card, it is "flattened" to avoid z-index weaving +// from other cards. Tip: Best way to test this is with multiple Advanced Camera +// Cards, opening a submenu on the 1st (e.g. media players) and verifying the +// menu of the 2nd card is not visible through the opened submenu on the 1st. +:host(:not(:hover)) { + z-index: #{$z-index-card-flatten}; +} + advanced-camera-card-loading { position: absolute; inset: 0; - z-index: 1; + z-index: #{$z-index-loading}; } :host([dimmable]:not([interaction])) { @@ -60,17 +68,11 @@ advanced-camera-card-loading { div.main { position: relative; - // Required to keep curved corners on the card. - overflow: auto; - width: 100%; height: 100%; margin: auto; display: block; - // Necessary to get Safari to show border-radius correctly. - transform: translateZ(0); - // Hide scrollbar: Firefox scrollbar-width: none; // Hide scrollbar: IE and Edge @@ -82,20 +84,6 @@ div.main::-webkit-scrollbar { display: none; } -// Need to apply the border radius on the container level, as the ha-card has -// overflow visible in order to allow a submenu to extend beyond the card -// boundary. Need to be able to selectively curve top or bottom depending on -// whether the outside menu is being shown. There's no way to select 'preceding -// element' in CSS, so this must be implemented in JS. -div.main.curve-top { - border-top-left-radius: var(--ha-card-border-radius, 4px); - border-top-right-radius: var(--ha-card-border-radius, 4px); -} -div.main.curve-bottom { - border-bottom-left-radius: var(--ha-card-border-radius, 4px); - border-bottom-right-radius: var(--ha-card-border-radius, 4px); -} - ha-card { display: flex; flex-direction: column; @@ -181,30 +169,12 @@ web-dialog::part(dialog) { background: transparent; } -/************************************* - * "Outside" style for menu/status bar - *************************************/ - -// Style is set on the children themselves, to avoid the need for the parent -// outlay to prevent overflow (which needs to be enabled to menu items to be -// visible). See similar approach in overlay.scss for overlay. - -.outerlay[data-position='top'] > *:first-child { - border-top-left-radius: var(--ha-card-border-radius, 4px); - border-top-right-radius: var(--ha-card-border-radius, 4px); -} - -.outerlay[data-position='bottom'] > *:last-child { - border-bottom-left-radius: var(--ha-card-border-radius, 4px); - border-bottom-right-radius: var(--ha-card-border-radius, 4px); -} - /******************* * Menu hover styles *******************/ advanced-camera-card-menu { - z-index: 2; + z-index: #{$z-index-menu}; } advanced-camera-card-menu[data-style*='hover'] { @@ -223,7 +193,7 @@ ha-card:hover *************************/ advanced-camera-card-status-bar { - z-index: 1; + z-index: #{$z-index-status-bar}; } advanced-camera-card-status-bar[data-style*='hover'] { diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss index f6e1ed1c..0cfe88fd 100644 --- a/src/scss/drawer-inject.scss +++ b/src/scss/drawer-inject.scss @@ -1,3 +1,5 @@ +@import './z-index.scss'; + :host { // Drawer width sizes to contents. width: unset; @@ -28,7 +30,7 @@ // Drawer renders behind the menu/status-bar overlay (otherwise the drawer // controls render on top of menu items) - z-index: 10; + z-index: #{$z-index-drawer}; } :host([location='right']) #d { diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index f4ef72b2..dde60307 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -9,7 +9,6 @@ div.control-surround { position: absolute; bottom: 50%; transform: translateY(50%); - z-index: 0; padding-top: $drawer-padding-extend; padding-bottom: $drawer-padding-extend; } @@ -48,13 +47,6 @@ advanced-camera-card-icon.control { transition: opacity 0.5s ease; } -:host([open]) advanced-camera-card-icon.control, -advanced-camera-card-icon.control:hover { - // When the drawer is open or hovered make the button to close it more - // prominent. - opacity: 1; -} - :host([location='left']) advanced-camera-card-icon.control { border-top-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size; diff --git a/src/scss/media-grid.scss b/src/scss/media-grid.scss index acc48f93..d0f085ed 100644 --- a/src/scss/media-grid.scss +++ b/src/scss/media-grid.scss @@ -43,10 +43,6 @@ var(--advanced-camera-card-grid-column-size) ) ); - - // When at item is selected, it may be enlarged -- it should render "in-front" - // of the other cameras that will then "move out of the way". - z-index: 2; } slot { diff --git a/src/scss/menu-button.scss b/src/scss/menu-button.scss new file mode 100644 index 00000000..9290dc75 --- /dev/null +++ b/src/scss/menu-button.scss @@ -0,0 +1,12 @@ +@use './button.scss'; + +ha-icon-button { + // Icons in the menu are expected to follow Advanced Camera Card theming + // unless they are active (in which case we want to take advantage of the + // whatever styling is appropriate, e.g. light icon partially lit). + --state-unavailable-color: var(--advanced-camera-card-button-color); + --state-inactive-color: var(--advanced-camera-card-button-color); + + color: var(--advanced-camera-card-menu-button-inactive-color); + background-color: var(--advanced-camera-card-menu-button-background); +} diff --git a/src/scss/menu.scss b/src/scss/menu.scss index d33f4449..a2e31daa 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -1,4 +1,4 @@ -@use './button.scss'; +@use './menu-button.scss'; :host { --advanced-camera-card-menu-button-size: 40px; @@ -10,9 +10,6 @@ display: flex; flex-direction: row; justify-content: space-between; - - // Allow submenus to overflow the menu "bar". - overflow: visible; } :host([data-style='outside']) { @@ -142,13 +139,5 @@ div.opposing { background: var(--advanced-camera-card-menu-background); } -// Icons in the menu are expected to follow Advanced Camera Card theming unless they are -// active (in which case we want to take advantage of the whatever styling is -// appropriate, e.g. light icon partially lit). -:host ha-icon-button { - --state-unavailable-color: var(--advanced-camera-card-button-color); - --state-inactive-color: var(--advanced-camera-card-button-color); -} - // Further theme related styling is dynamically applied by `menu.ts`, see // `_renderPerInstanceStyle`. diff --git a/src/scss/overlay.scss b/src/scss/overlay.scss index 0b31ee64..a65f3c69 100644 --- a/src/scss/overlay.scss +++ b/src/scss/overlay.scss @@ -55,31 +55,3 @@ slot[name='bottom'], slot[name='right'] { justify-content: flex-end; } - -/******************************* - * Match rounded corners to card - *******************************/ - -// Style is set on the children themselves, to avoid the need for the parent -// outlay to prevent overflow (which needs to be enabled to menu items to be -// visible). See similar approach in card.scss for outerlay. - -::slotted([slot='top']:first-child), -::slotted([slot='left']:first-child) { - border-top-left-radius: var(--ha-card-border-radius, 4px); -} - -::slotted([slot='top']:first-child), -::slotted([slot='right']:first-child) { - border-top-right-radius: var(--ha-card-border-radius, 4px); -} - -::slotted([slot='bottom']:last-child), -::slotted([slot='left']:last-child) { - border-bottom-left-radius: var(--ha-card-border-radius, 4px); -} - -::slotted([slot='bottom']:last-child), -::slotted([slot='right']:last-child) { - border-bottom-right-radius: var(--ha-card-border-radius, 4px); -} diff --git a/src/scss/ptz.scss b/src/scss/ptz.scss index 4af81493..5acabeb5 100644 --- a/src/scss/ptz.scss +++ b/src/scss/ptz.scss @@ -55,7 +55,7 @@ .ptz-move, .ptz-zoom, -.ptz-home { +.ptz-presets { position: relative; background-color: rgba(0, 0, 0, 0.3); } @@ -68,27 +68,28 @@ } :host([data-orientation='horizontal']) .ptz .ptz-zoom, -:host([data-orientation='horizontal']) .ptz .ptz-home { +:host([data-orientation='horizontal']) .ptz .ptz-presets { width: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5); } :host([data-orientation='vertical']) .ptz .ptz-zoom, -:host([data-orientation='vertical']) .ptz .ptz-home { +:host([data-orientation='vertical']) .ptz .ptz-presets { height: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5); } .ptz-zoom, -.ptz-home { +.ptz-presets { border-radius: var(--ha-card-border-radius, 4px); } /*********** * PTZ Icons ***********/ -advanced-camera-card-icon { +.ptz-move advanced-camera-card-icon { position: absolute; --mdc-icon-size: var(--advanced-camera-card-ptz-icon-size); } -advanced-camera-card-icon:not(.disabled) { +advanced-camera-card-icon:not(.disabled), +advanced-camera-card-submenu:not(.disabled) { cursor: pointer; } .disabled { @@ -115,34 +116,17 @@ advanced-camera-card-icon:not(.disabled) { transform: translateY(-50%); } -:host([data-orientation='vertical']) .zoom_in { - right: 5px; - top: 50%; +.ptz-presets, +.ptz-zoom { + display: flex; + align-items: center; + justify-content: space-evenly; } -:host([data-orientation='vertical']) .zoom_out { - left: 5px; - top: 50%; +:host([data-orientation='vertical']) .ptz-presets, +:host([data-orientation='vertical']) .ptz-zoom { + flex-direction: row; } -:host([data-orientation='horizontal']) .zoom_in { - left: 50%; - top: 5px; -} -:host([data-orientation='horizontal']) .zoom_out { - left: 50%; - bottom: 5px; -} - -:host([data-orientation='vertical']) .zoom_in, -:host([data-orientation='vertical']) .zoom_out { - transform: translateY(-50%); -} -:host([data-orientation='horizontal']) .zoom_in, -:host([data-orientation='horizontal']) .zoom_out { - transform: translateX(-50%); -} - -.home { - top: 50%; - left: 50%; - transform: translateX(-50%) translateY(-50%); +:host([data-orientation='horizontal']) .ptz-presets, +:host([data-orientation='horizontal']) .ptz-zoom { + flex-direction: column; } diff --git a/src/scss/submenu.scss b/src/scss/submenu.scss index 7f824c7e..4f5b092f 100644 --- a/src/scss/submenu.scss +++ b/src/scss/submenu.scss @@ -1,11 +1,10 @@ @use './button.scss'; +@import './z-index.scss'; :host { pointer-events: auto; -} -mwc-list-item { - z-index: 20; + --mdc-menu-z-index: #{$z-index-submenu}; } ha-icon-button { diff --git a/src/scss/z-index.scss b/src/scss/z-index.scss new file mode 100644 index 00000000..76ccfac9 --- /dev/null +++ b/src/scss/z-index.scss @@ -0,0 +1,22 @@ +/************ + * Managing z-indicies and stacking contexts is very challenging on the card, + * due to the volume of different potentially overlapping surfaces. In + * particular, care must be taken to not generate new stacking contexts + * inadvertently which would make "z-index" weaving challenging (e.g. submenu + * shown for PTZ presets, needs to render "over" the media drawer open/close + * control). + *************/ + +// More-info dialog box has a z-index of 8, so everything meaningful needs to be +// below that. + +$z-index-loading: 6; +$z-index-submenu: 5; + +// Need menu to render above drawer (so the drawer button is below menu-submenus) +$z-index-menu: 4; +$z-index-drawer: 3; +$z-index-status-bar: 2; + +// The base z-index when the card is not hovered over. See note in card.scss . +$z-index-card-flatten: 0; diff --git a/src/types.ts b/src/types.ts index c05aba43..d8ee58fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,6 +153,10 @@ export interface Icon { fallback?: string; } +export interface Interaction { + action: string; +} + // ************************************************************************* // Home Assistant API types. // ************************************************************************* diff --git a/src/utils/action.ts b/src/utils/action.ts index f955e45b..58cc1d82 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -9,7 +9,7 @@ import { PTZAction } from '../config/ptz.js'; import { ActionPhase, ActionType, - Actions, + ActionsConfig, AdvancedCameraCardGeneralAction, AdvancedCameraCardUserSpecifiedView, CameraSelectActionConfig, @@ -220,6 +220,7 @@ export function createInternalCallbackAction( export function createPerformAction( perform_action: string, options?: { + cardID?: string; data?: ServiceCallRequest['serviceData']; target?: ServiceCallRequest['target']; }, @@ -229,6 +230,7 @@ export function createPerformAction( perform_action: perform_action, ...(options?.target && { target: options.target }), ...(options?.data && { data: options.data }), + ...(options?.cardID && { card_id: options.cardID }), }; } @@ -240,13 +242,19 @@ export function createPerformAction( */ export function getActionConfigGivenAction( interaction?: string, - config?: Actions | null, + config?: ActionsConfig | null, ): ActionType | ActionType[] | null { if (!interaction || !config) { return null; } if (interaction === 'tap' && config.tap_action) { return config.tap_action; + } else if (interaction === 'tap' && config.entity) { + // As a special case, if there is an entity specified, but no action, a + // more-info action is assumed (e.g. a menu-state-icon). + return { + action: 'more-info', + }; } else if (interaction === 'hold' && config.hold_action) { return config.hold_action; } else if (interaction === 'double_tap' && config.double_tap_action) { diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 58cd8f2d..44792ce2 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -1,8 +1,13 @@ import { handleActionConfig } from '@dermotduffy/custom-card-helpers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MenuController } from '../../src/components-lib/menu-controller'; +import { SubmenuItem } from '../../src/components/submenu/types'; import { MenuConfig, menuConfigSchema } from '../../src/config/types'; -import { createInteractionEvent, createLitElement } from '../test-utils'; +import { + createInteractionActionEvent, + createLitElement, + createSubmenuInteractionActionEvent, +} from '../test-utils'; vi.mock('@dermotduffy/custom-card-helpers'); vi.mock('../../src/utils/ha'); @@ -365,7 +370,7 @@ describe('MenuController', () => { describe('should handle actions', () => { it('should bail without config', () => { const controller = new MenuController(createLitElement()); - controller.actionHandler(createInteractionEvent('tap')); + controller.handleAction(createInteractionActionEvent('tap')); expect(vi.mocked(handleActionConfig)).not.toBeCalled(); }); @@ -376,7 +381,7 @@ describe('MenuController', () => { const controller = new MenuController(host); - controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); + controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig); expect(handler).toBeCalledWith( expect.objectContaining({ detail: { action: [action], config: tapActionConfig }, @@ -392,7 +397,9 @@ describe('MenuController', () => { const controller = new MenuController(host); - controller.actionHandler(createInteractionEvent('tap', tapActionConfig)); + controller.handleAction( + createSubmenuInteractionActionEvent('tap', tapActionConfig as SubmenuItem), + ); expect(handler).toBeCalledWith( expect.objectContaining({ detail: { action: [action], config: tapActionConfig }, @@ -407,7 +414,7 @@ describe('MenuController', () => { const controller = new MenuController(host); - controller.actionHandler(createInteractionEvent('tap'), tapActionConfigMulti); + controller.handleAction(createInteractionActionEvent('tap'), tapActionConfigMulti); expect(handler).toBeCalledWith( expect.objectContaining({ @@ -429,7 +436,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); + controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig); expect(controller.isExpanded()).toBeFalsy(); }); @@ -445,7 +452,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(createInteractionEvent('end_tap'), { + controller.handleAction(createInteractionActionEvent('end_tap'), { end_tap_action: action, }); expect(controller.isExpanded()).toBeFalsy(); @@ -465,7 +472,7 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(createInteractionEvent('start_tap'), { + controller.handleAction(createInteractionActionEvent('start_tap'), { start_tap_action: action, end_tap_action: action, }); @@ -484,7 +491,7 @@ describe('MenuController', () => { controller.setExpanded(false); expect(controller.isExpanded()).toBeFalsy(); - controller.actionHandler(createInteractionEvent('tap'), { + controller.handleAction(createInteractionActionEvent('tap'), { camera_entity: 'foo', tap_action: menuToggleAction, }); @@ -503,7 +510,10 @@ describe('MenuController', () => { controller.setExpanded(true); expect(controller.isExpanded()).toBeTruthy(); - controller.actionHandler(createInteractionEvent('end_tap'), tapActionConfig); + controller.handleAction( + createInteractionActionEvent('end_tap'), + tapActionConfig, + ); expect(controller.isExpanded()).toBeTruthy(); }); }); diff --git a/tests/components-lib/ptz/ptz-controller.test.ts b/tests/components-lib/ptz/ptz-controller.test.ts index 341ca2f4..87573982 100644 --- a/tests/components-lib/ptz/ptz-controller.test.ts +++ b/tests/components-lib/ptz/ptz-controller.test.ts @@ -200,6 +200,7 @@ describe('PTZController', () => { down: ['relative'], zoomIn: ['relative'], zoomOut: ['relative'], + presets: ['door', 'window'], }, }), }, @@ -215,6 +216,148 @@ describe('PTZController', () => { }, }); }); + + it('only presets', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + const store = createStore([ + { + cameraID: 'camera.office', + capabilities: new Capabilities({ + ptz: { + presets: ['door', 'window'], + }, + }), + }, + ]); + + const cameraManager = createCameraManager(store); + controller.setCamera(cameraManager, 'camera.office'); + + expect(controller.getPTZActions()['presets']).toEqual([ + { + actions: { + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'preset', + ptz_preset: 'door', + }, + }, + preset: 'door', + }, + { + actions: { + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'preset', + ptz_preset: 'window', + }, + }, + preset: 'window', + }, + ]); + }); + + it('should return digital PTZ actions without camera capabilities', () => { + const controller = new PTZController(document.createElement('div')); + controller.setConfig(createConfig()); + + expect(controller.getPTZActions()).toEqual({ + down: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'down', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'down', + ptz_phase: 'start', + }, + }, + home: { + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + }, + }, + left: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'left', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'left', + ptz_phase: 'start', + }, + }, + right: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'right', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'right', + ptz_phase: 'start', + }, + }, + up: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'up', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'up', + ptz_phase: 'start', + }, + }, + zoom_in: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'zoom_in', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'zoom_in', + ptz_phase: 'start', + }, + }, + zoom_out: { + end_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'zoom_out', + ptz_phase: 'stop', + }, + start_tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'ptz_multi', + ptz_action: 'zoom_out', + ptz_phase: 'start', + }, + }, + }); + }); }); describe('should handle action', () => { @@ -276,103 +419,4 @@ describe('PTZController', () => { expect(handler).not.toBeCalled(); }); }); - - describe('should identify useful actions', () => { - it('without a camera', () => { - const controller = new PTZController(document.createElement('div')); - expect(controller.hasUsefulAction()).toEqual({ - pt: true, - z: true, - home: true, - }); - }); - - it('without camera PTZ capabilities', () => { - const controller = new PTZController(document.createElement('div')); - - const store = createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - ptz: {}, - }), - }, - ]); - const cameraManager = createCameraManager(store); - controller.setCamera(cameraManager, 'camera.office'); - - expect(controller.hasUsefulAction()).toEqual({ - pt: true, - z: true, - home: true, - }); - }); - - it('with camera pan and tilt capabilities', () => { - const controller = new PTZController(document.createElement('div')); - const store = createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - ptz: { - left: ['relative'], - right: ['relative'], - up: ['relative'], - down: ['relative'], - }, - }), - }, - ]); - controller.setCamera(createCameraManager(store), 'camera.office'); - - expect(controller.hasUsefulAction()).toEqual({ - pt: true, - z: false, - home: false, - }); - }); - - it('with camera zoom capabilities', () => { - const controller = new PTZController(document.createElement('div')); - const store = createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - ptz: { - zoomIn: ['relative'], - zoomOut: ['relative'], - }, - }), - }, - ]); - controller.setCamera(createCameraManager(store), 'camera.office'); - - expect(controller.hasUsefulAction()).toEqual({ - pt: false, - z: true, - home: false, - }); - }); - - it('with camera presets', () => { - const controller = new PTZController(document.createElement('div')); - const store = createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - ptz: { - presets: ['door'], - }, - }), - }, - ]); - controller.setCamera(createCameraManager(store), 'camera.office'); - - expect(controller.hasUsefulAction()).toEqual({ - pt: false, - z: false, - home: true, - }); - }); - }); }); diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts index 198ab32c..4b898671 100644 --- a/tests/components-lib/status-bar-controller.test.ts +++ b/tests/components-lib/status-bar-controller.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { StatusBarController } from '../../src/components-lib/status-bar-controller'; import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types'; import { setOrRemoveAttribute } from '../../src/utils/basic'; -import { createInteractionEvent, createLitElement } from '../test-utils'; +import { createInteractionActionEvent, createLitElement } from '../test-utils'; const createConfig = (config?: unknown): StatusBarConfig => { return statusBarConfigSchema.parse(config); @@ -300,7 +300,7 @@ describe('StatusBarController', () => { host.addEventListener('advanced-camera-card:action:execution-request', handler); const controller = new StatusBarController(host); - controller.actionHandler(createInteractionEvent('tap')); + controller.actionHandler(createInteractionActionEvent('tap')); expect(handler).not.toBeCalled(); }); @@ -318,7 +318,7 @@ describe('StatusBarController', () => { tap_action: action, }; - controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); + controller.actionHandler(createInteractionActionEvent('tap'), tapActionConfig); expect(handler).toBeCalledWith( expect.objectContaining({ diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 75c71f74..f12703e7 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,4 +1,4 @@ -import { CurrentUser, HASSDomEvent } from '@dermotduffy/custom-card-helpers'; +import { CurrentUser } from '@dermotduffy/custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { LitElement } from 'lit'; import screenfull from 'screenfull'; @@ -39,9 +39,9 @@ import { StatusBarItemManager } from '../src/card-controller/status-bar-item-man import { StyleManager } from '../src/card-controller/style-manager'; import { TriggersManager } from '../src/card-controller/triggers-manager'; import { ViewManager } from '../src/card-controller/view/view-manager'; +import { SubmenuInteraction, SubmenuItem } from '../src/components/submenu/types'; import { ConditionStateManager } from '../src/conditions/state-manager'; import { - ActionsConfig, AdvancedCameraCardConfig, CameraConfig, InternalAdvancedCameraCardCustomAction, @@ -52,7 +52,12 @@ import { internalAdvancedCameraCardCustomActionSchema, performanceConfigSchema, } from '../src/config/types'; -import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types'; +import { + CapabilitiesRaw, + ExtendedHomeAssistant, + Interaction, + MediaLoadedInfo, +} from '../src/types'; import { HassStateDifference } from '../src/utils/ha'; import { Device } from '../src/utils/ha/registry/device/types'; import { EntityRegistryManager } from '../src/utils/ha/registry/entity'; @@ -520,14 +525,24 @@ export const flushPromises = async (): Promise => { await new Promise(process.nextTick); }; -export const createInteractionEvent = ( +export const createInteractionActionEvent = ( action: string, - config?: ActionsConfig, -): HASSDomEvent<{ action: string; config?: ActionsConfig }> => { - return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', { +): CustomEvent => { + return new CustomEvent('@action', { detail: { action: action, - config: config, + }, + }); +}; + +export const createSubmenuInteractionActionEvent = ( + action: string, + item: SubmenuItem, +): CustomEvent => { + return new CustomEvent('@action', { + detail: { + action, + item, }, }); }; diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index b097616e..70193e52 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -10,6 +10,7 @@ import { createInternalCallbackAction, createLogAction, createMediaPlayerAction, + createPerformAction, createPTZAction, createPTZControlsAction, createPTZDigitalAction, @@ -273,6 +274,24 @@ describe('createInternalCallbackAction', () => { }); }); +describe('createPerformAction', () => { + it('should create perform action', () => { + expect( + createPerformAction('toggle', { + cardID: 'card_id', + target: { entity_id: 'light.office_main_lights' }, + data: {}, + }), + ).toEqual({ + action: 'perform-action', + perform_action: 'toggle', + card_id: 'card_id', + target: { entity_id: 'light.office_main_lights' }, + data: {}, + }); + }); +}); + describe('getActionConfigGivenAction', () => { const action = actionSchema.parse({ action: 'fire-dom-event', @@ -293,6 +312,18 @@ describe('getActionConfigGivenAction', () => { expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action); }); + it('should handle default tap action without an entity', () => { + expect(getActionConfigGivenAction('tap', {})).toBeNull(); + }); + + it('should handle default tap action with an entity', () => { + expect( + getActionConfigGivenAction('tap', { entity: 'light.office_main_lights' }), + ).toEqual({ + action: 'more-info', + }); + }); + it('should handle hold actions', () => { expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action); });