feat: Add UI support for PTZ presets (#1920)

- Closes #1889

Whilst a simple change theoretically, the card has such a collection of
surfaces that can overlap other surfaces, it's challenging to get this
to work right! There's a real chance this will have broken something
z-index related (e.g. X overlaps Y when it should not), or (for related
reasons) broken curver corners on the card.
This commit is contained in:
Dermot Duffy
2025-02-27 20:22:12 -08:00
committed by GitHub
parent bb147a45fd
commit 0432eea4b8
37 changed files with 833 additions and 647 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ live:
| Option | Default | Description | | 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_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 | | `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. | | `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. |
+7 -11
View File
@@ -1,7 +1,7 @@
import { HASSDomEvent } from '@dermotduffy/custom-card-helpers';
import { LitElement } from 'lit'; import { LitElement } from 'lit';
import { orderBy } from 'lodash-es'; import { orderBy } from 'lodash-es';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
import { SubmenuInteraction } from '../components/submenu/types.js';
import { import {
MENU_PRIORITY_MAX, MENU_PRIORITY_MAX,
type ActionType, type ActionType,
@@ -9,6 +9,7 @@ import {
type MenuConfig, type MenuConfig,
type MenuItem, type MenuItem,
} from '../config/types.js'; } from '../config/types.js';
import { Interaction } from '../types.js';
import { import {
convertActionToCardCustomAction, convertActionToCardCustomAction,
getActionConfigGivenAction, getActionConfigGivenAction,
@@ -86,21 +87,16 @@ export class MenuController {
this.setExpanded(!this._expanded); this.setExpanded(!this._expanded);
} }
public actionHandler( public handleAction(
ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>, ev: CustomEvent<Interaction & Partial<SubmenuInteraction>>,
config?: ActionsConfig, buttonConfig?: ActionsConfig,
): void { ): void {
// These interactions should only be handled by the menu, as nothing // These interactions should only be handled by the menu, as nothing
// upstream has the user-provided configuration. // upstream has the user-provided configuration.
ev.stopPropagation(); ev.stopPropagation();
// If the event itself contains a configuration then use that. This is // If the action is from a submenu, use the attached action config.
// useful in cases where the registration of the event handler does not have const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null;
// access to the actual desired configuration (e.g. action events generated
// by a submenu).
if (ev.detail.config) {
config = ev.detail.config;
}
if (!config) { if (!config) {
return; return;
} }
+71 -55
View File
@@ -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 { CameraManager } from '../../camera-manager/manager';
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request'; import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request';
import { PTZAction } from '../../config/ptz'; import { PTZAction } from '../../config/ptz';
import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types'; import { Actions, ActionsConfig, PTZControlsConfig } from '../../config/types';
import { Interaction } from '../../types';
import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action'; import { createPTZMultiAction, getActionConfigGivenAction } from '../../utils/action';
import { PTZActionNameToMultiAction, PTZActionPresence } from './types'; import { SubmenuInteraction } from '../../components/submenu/types';
import { PTZControllerActions } from './types';
export class PTZController { export class PTZController {
private _host: HTMLElement; private _host: HTMLElement;
@@ -47,9 +49,11 @@ export class PTZController {
} }
public handleAction( public handleAction(
ev: HASSDomEvent<{ action: string }>, ev: CustomEvent<Interaction & Partial<SubmenuInteraction>>,
config?: ActionsConfig | null, buttonConfig?: ActionsConfig | null,
): void { ): void {
const config: ActionsConfig | null = buttonConfig ?? ev.detail.item ?? null;
// Nothing else has the configuration for this action, so don't let it // Nothing else has the configuration for this action, so don't let it
// propagate further. // propagate further.
ev.stopPropagation(); 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 { public shouldDisplay(): boolean {
return this._forceVisibility !== undefined return this._forceVisibility !== undefined
? this._forceVisibility ? this._forceVisibility
@@ -103,8 +79,15 @@ export class PTZController {
: this._config?.mode === 'on'; : this._config?.mode === 'on';
} }
public getPTZActions(): PTZActionNameToMultiAction { public getPTZActions(): PTZControllerActions {
const getDefaultActions = (options?: { const cameraCapabilities = this._cameraID
? this._cameraManager?.getCameraCapabilities(this._cameraID)
: null;
const hasRealPTZCapability =
cameraCapabilities && cameraCapabilities.hasPTZCapability();
const ptzCapabilities = cameraCapabilities?.getPTZCapabilities();
const getContinuousActions = (options?: {
ptzAction?: PTZAction; ptzAction?: PTZAction;
preset?: string; preset?: string;
}): Actions => ({ }): Actions => ({
@@ -120,28 +103,61 @@ export class PTZController {
}), }),
}); });
const actions: PTZActionNameToMultiAction = {}; const getDiscreteAction = (options?: {
actions.up = getDefaultActions({ ptzAction?: PTZAction;
ptzAction: 'up', preset?: string;
}): Actions => ({
tap_action: createPTZMultiAction({
ptzAction: options?.ptzAction,
ptzPreset: options?.preset,
}),
}); });
actions.down = getDefaultActions({
ptzAction: 'down', const actions: PTZControllerActions = {};
}); if (!hasRealPTZCapability || ptzCapabilities?.up) {
actions.left = getDefaultActions({ actions.up = getContinuousActions({
ptzAction: 'left', ptzAction: 'up',
}); });
actions.right = getDefaultActions({ }
ptzAction: 'right', if (!hasRealPTZCapability || ptzCapabilities?.down) {
}); actions.down = getContinuousActions({
actions.zoom_in = getDefaultActions({ ptzAction: 'down',
ptzAction: 'zoom_in', });
}); }
actions.zoom_out = getDefaultActions({ if (!hasRealPTZCapability || ptzCapabilities?.left) {
ptzAction: 'zoom_out', actions.left = getContinuousActions({
}); ptzAction: 'left',
actions.home = { });
tap_action: createPTZMultiAction(), }
}; 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; return actions;
} }
} }
+9 -8
View File
@@ -10,12 +10,13 @@ declare module 'view' {
} }
} }
export type PTZActionNameToMultiAction = { interface PTZPresetAction {
[K in PTZControlAction]?: Actions; preset: string;
}; actions: Actions;
export interface PTZActionPresence {
pt: boolean;
z: boolean;
home: boolean;
} }
export type PTZControllerActions = {
[K in PTZControlAction]?: Actions;
} & {
presets?: PTZPresetAction[];
};
+1
View File
@@ -399,6 +399,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
${this._renderNextPrevious('right', neighbors)} ${this._renderNextPrevious('right', neighbors)}
</advanced-camera-card-carousel> </advanced-camera-card-carousel>
<advanced-camera-card-ptz <advanced-camera-card-ptz
.hass=${this.hass}
.config=${this.liveConfig.controls.ptz} .config=${this.liveConfig.controls.ptz}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(view, this.viewFilterCameraID)} .cameraID=${getStreamCameraID(view, this.viewFilterCameraID)}
+9 -8
View File
@@ -10,7 +10,8 @@ import { hasAction } from '../utils/action.js';
import { getEntityTitle } from '../utils/ha/index.js'; import { getEntityTitle } from '../utils/ha/index.js';
import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js'; import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js';
import './icon.js'; import './icon.js';
import './submenu.js'; import './submenu/select-button.js';
import './submenu/submenu-button';
@customElement('advanced-camera-card-menu') @customElement('advanced-camera-card-menu')
export class AdvancedCameraCardMenu extends LitElement { export class AdvancedCameraCardMenu extends LitElement {
@@ -44,20 +45,20 @@ export class AdvancedCameraCardMenu extends LitElement {
} }
if (button.type === 'custom:advanced-camera-card-menu-submenu') { if (button.type === 'custom:advanced-camera-card-menu-submenu') {
return html` <advanced-camera-card-submenu return html` <advanced-camera-card-submenu-button
.hass=${this.hass} .hass=${this.hass}
.submenu=${button} .submenu=${button}
@action=${(ev) => this._controller.actionHandler(ev)} @action=${(ev) => this._controller.handleAction(ev)}
> >
</advanced-camera-card-submenu>`; </advanced-camera-card-submenu-button>`;
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') { } else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
return html` <advanced-camera-card-submenu-select return html` <advanced-camera-card-submenu-select-button
.hass=${this.hass} .hass=${this.hass}
.submenuSelect=${button} .submenuSelect=${button}
.entityRegistryManager=${this.entityRegistryManager} .entityRegistryManager=${this.entityRegistryManager}
@action=${(ev) => this._controller.actionHandler(ev)} @action=${(ev) => this._controller.handleAction(ev)}
> >
</advanced-camera-card-submenu-select>`; </advanced-camera-card-submenu-select-button>`;
} }
const title = const title =
@@ -73,7 +74,7 @@ export class AdvancedCameraCardMenu extends LitElement {
hasDoubleClick: hasAction(button.double_tap_action), hasDoubleClick: hasAction(button.double_tap_action),
})} })}
.label=${title ?? ''} .label=${title ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, button)} @action=${(ev) => this._controller.handleAction(ev, button)}
> >
<advanced-camera-card-icon <advanced-camera-card-icon
?allow-override-non-active-styles=${true} ?allow-override-non-active-styles=${true}
+71 -26
View File
@@ -1,4 +1,4 @@
import { HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
@@ -12,15 +12,21 @@ import { classMap } from 'lit/directives/class-map.js';
import { actionHandler } from '../action-handler-directive.js'; import { actionHandler } from '../action-handler-directive.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { PTZController } from '../components-lib/ptz/ptz-controller.js'; import { PTZController } from '../components-lib/ptz/ptz-controller.js';
import { PTZActionPresence } from '../components-lib/ptz/types.js'; import { PTZControllerActions } from '../components-lib/ptz/types.js';
import { Actions, PTZControlsConfig } from '../config/types.js'; import { Actions, PTZControlsConfig } from '../config/types.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import ptzStyle from '../scss/ptz.scss'; import ptzStyle from '../scss/ptz.scss';
import { Interaction } from '../types.js';
import { hasAction } from '../utils/action.js'; import { hasAction } from '../utils/action.js';
import { prettifyTitle } from '../utils/basic.js';
import './icon.js'; import './icon.js';
import './submenu';
import { SubmenuInteraction, SubmenuItem } from './submenu/types.js';
@customElement('advanced-camera-card-ptz') @customElement('advanced-camera-card-ptz')
export class AdvancedCameraCardPTZ extends LitElement { export class AdvancedCameraCardPTZ extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public config?: PTZControlsConfig; public config?: PTZControlsConfig;
@@ -34,8 +40,7 @@ export class AdvancedCameraCardPTZ extends LitElement {
public forceVisibility?: boolean; public forceVisibility?: boolean;
protected _controller = new PTZController(this); protected _controller = new PTZController(this);
protected _actions = this._controller.getPTZActions(); protected _actions: PTZControllerActions | null = null;
protected _actionPresence: PTZActionPresence | null = null;
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) { if (changedProps.has('config')) {
@@ -48,7 +53,7 @@ export class AdvancedCameraCardPTZ extends LitElement {
this._controller.setForceVisibility(this.forceVisibility); this._controller.setForceVisibility(this.forceVisibility);
} }
if (changedProps.has('cameraID') || changedProps.has('cameraManager')) { if (changedProps.has('cameraID') || changedProps.has('cameraManager')) {
this._actionPresence = this._controller.hasUsefulAction(); this._actions = this._controller.getPTZActions();
} }
} }
@@ -60,49 +65,89 @@ export class AdvancedCameraCardPTZ extends LitElement {
const renderIcon = ( const renderIcon = (
name: string, name: string,
icon: string, icon: string,
actions?: Actions | null, options?: {
actions?: Actions | null;
renderWithoutAction?: boolean;
},
): TemplateResult => { ): TemplateResult => {
const classes = { const classes = {
[name]: true, [name]: true,
disabled: !actions, disabled: !options?.actions && !options?.renderWithoutAction,
}; };
return actions return options?.actions || options?.renderWithoutAction
? html`<advanced-camera-card-icon ? html`<advanced-camera-card-icon
class=${classMap(classes)} class=${classMap(classes)}
.icon=${{ icon: icon }} .icon=${{ icon: icon }}
.actionHandler=${actionHandler({
hasHold: hasAction(actions?.hold_action),
hasDoubleClick: hasAction(actions?.double_tap_action),
})}
.title=${localize(`elements.ptz.${name}`)} .title=${localize(`elements.ptz.${name}`)}
@action=${(ev: HASSDomEvent<{ action: string }>) => .actionHandler=${options.actions
this._controller.handleAction(ev, actions)} ? actionHandler({
hasHold: hasAction(options.actions?.hold_action),
hasDoubleClick: hasAction(options.actions?.double_tap_action),
})
: undefined}
@action=${(ev: CustomEvent<Interaction>) =>
options.actions && this._controller.handleAction(ev, options.actions)}
></advanced-camera-card-icon>` ></advanced-camera-card-icon>`
: html``; : 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(); const config = this._controller.getConfig();
return html` <div class="ptz"> return html` <div class="ptz">
${!config?.hide_pan_tilt && this._actionPresence?.pt ${!config?.hide_pan_tilt &&
(this._actions?.left ||
this._actions?.right ||
this._actions?.up ||
this._actions?.down)
? html`<div class="ptz-move"> ? html`<div class="ptz-move">
${renderIcon('right', 'mdi:arrow-right', this._actions.right)} ${renderIcon('right', 'mdi:arrow-right', { actions: this._actions?.right })}
${renderIcon('left', 'mdi:arrow-left', this._actions.left)} ${renderIcon('left', 'mdi:arrow-left', { actions: this._actions?.left })}
${renderIcon('up', 'mdi:arrow-up', this._actions.up)} ${renderIcon('up', 'mdi:arrow-up', { actions: this._actions?.up })}
${renderIcon('down', 'mdi:arrow-down', this._actions.down)} ${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })}
</div>` </div>`
: ''} : ''}
${!config?.hide_zoom && this._actionPresence?.z ${!config?.hide_zoom && (this._actions?.zoom_in || this._actions?.zoom_out)
? html` <div class="ptz-zoom"> ? html` <div class="ptz-zoom">
${renderIcon('zoom_in', 'mdi:plus', this._actions.zoom_in)} ${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })}
${renderIcon('zoom_out', 'mdi:minus', this._actions.zoom_out)} ${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
</div>` </div>`
: html``} : html``}
${!config?.hide_home && this._actionPresence?.home ${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length)
? html`<div class="ptz-home"> ? html`<div class="ptz-presets">
${renderIcon('home', 'mdi:home', this._actions.home)} ${renderIcon('home', 'mdi:home', { actions: this._actions?.home })}
${presetSubmenuItems?.length
? html`<advanced-camera-card-submenu
class="presets"
.hass=${this.hass}
.items=${presetSubmenuItems}
@action=${(ev: CustomEvent<SubmenuInteraction>) =>
this._controller.handleAction(ev)}
>
${renderIcon(
'presets',
config?.orientation === 'vertical'
? 'mdi:dots-vertical'
: 'mdi:dots-horizontal',
{
renderWithoutAction: true,
},
)}
</advanced-camera-card-submenu>`
: ''}
</div>` </div>`
: html``} : ''}
</div>`; </div>`;
} }
-273
View File
@@ -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`
<mwc-list-item
graphic=${ifDefined(item.icon || item.entity ? 'icon' : undefined)}
?twoline=${!!item.subtitle}
?selected=${item.selected}
?activated=${item.selected}
?disabled=${item.enabled === false}
aria-label="${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),
})}
>
<span style="${style}">${title ?? ''}</span>
${item.subtitle
? html`<span slot="secondary" style="${style}">${item.subtitle}</span>`
: ''}
<advanced-camera-card-icon
slot="graphic"
.hass=${this.hass}
.icon=${{
icon: item.icon,
entity: item.entity,
}}
style="${style}"
></advanced-camera-card-icon>
</mwc-list-item>
`;
}
protected render(): TemplateResult {
if (!this.submenu) {
return html``;
}
const items = this.submenu.items as MenuSubmenuItem[];
const style = styleMap(this.submenu.style || {});
return html`
<ha-button-menu
corner=${'BOTTOM_LEFT'}
@closed=${
// Prevent the submenu closing from closing anything upstream (e.g.
// selecting a submenu in the editor dialog should not close the
// editor, see https://github.com/dermotduffy/advanced-camera-card/issues/377).
(ev) => ev.stopPropagation()
}
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
>
<ha-icon-button
style="${style}"
slot="trigger"
.label=${this.submenu.title || ''}
.actionHandler=${actionHandler({
// Need to allow event to propagate upwards, as it's caught by the
// <ha-button-menu> trigger slot to open/close the menu. Further
// propagation is forbidden by the @click handler on
// <ha-button-menu>.
allowPropagation: true,
hasHold: hasAction(this.submenu.hold_action),
hasDoubleClick: hasAction(this.submenu.double_tap_action),
})}
>
<advanced-camera-card-icon
?allow-override-non-active-styles=${true}
style="${style}"
.hass=${this.hass}
.icon=${typeof this.submenu.icon === 'string'
? {
icon: this.submenu.icon,
}
: this.submenu.icon}
></advanced-camera-card-icon>
</ha-icon-button>
${items.map(this._renderItem.bind(this))}
</ha-button-menu>
`;
}
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<string, string>;
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<void> {
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` <advanced-camera-card-submenu
.hass=${this.hass}
.submenu=${this._generatedSubmenu}
></advanced-camera-card-submenu>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-submenu': AdvancedCameraCardSubmenu;
'advanced-camera-card-submenu-select': AdvancedCameraCardSubmenuSelect;
}
}
+95
View File
@@ -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`
<mwc-list-item
graphic=${ifDefined(item.icon || item.entity ? 'icon' : undefined)}
?twoline=${!!item.subtitle}
?selected=${item.selected}
?activated=${item.selected}
?disabled=${item.enabled === false}
aria-label="${title ?? ''}"
@action=${(ev: CustomEvent<SubmenuInteraction>) => {
// 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),
})}
>
<span style="${style}">${title ?? ''}</span>
${item.subtitle
? html`<span slot="secondary" style="${style}">${item.subtitle}</span>`
: ''}
<advanced-camera-card-icon
slot="graphic"
.hass=${this.hass}
.icon=${{
icon: item.icon,
entity: item.entity,
}}
style="${style}"
></advanced-camera-card-icon>
</mwc-list-item>
`;
}
protected render(): TemplateResult {
return html`
<ha-button-menu
fixed
corner=${'BOTTOM_LEFT'}
@closed=${
// Prevent the submenu closing from closing anything upstream (e.g.
// selecting a submenu in the editor dialog should not close the
// editor, see https://github.com/dermotduffy/advanced-camera-card/issues/377).
(ev) => ev.stopPropagation()
}
@click=${(ev: Event) => stopEventFromActivatingCardWideActions(ev)}
>
<slot slot="trigger"></slot>
${this.items?.map(this._renderItem.bind(this))}
</ha-button-menu>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(submenuStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-submenu': AdvancedCameraCardSubmenu;
}
}
+178
View File
@@ -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<string, string>;
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<void> {
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` <advanced-camera-card-submenu
.hass=${this.hass}
.items=${submenu?.items}
>
<ha-icon-button style="${style}" .label=${submenu.title || ''}>
<advanced-camera-card-icon
?allow-override-non-active-styles=${true}
style="${style}"
title=${submenu.title || ''}
.hass=${this.hass}
.icon=${typeof submenu.icon === 'string'
? {
icon: submenu.icon,
}
: submenu.icon}
></advanced-camera-card-icon>
</ha-icon-button>
</advanced-camera-card-submenu>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(menuButtonStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-submenu-select-button': AdvancedCameraCardSubmenuSelectButton;
}
}
+59
View File
@@ -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` <advanced-camera-card-submenu
.hass=${this.hass}
.items=${this.submenu?.items}
>
<ha-icon-button style="${style}" .label=${this.submenu.title || ''}>
<advanced-camera-card-icon
?allow-override-non-active-styles=${true}
style="${style}"
title=${this.submenu.title || ''}
.hass=${this.hass}
.icon=${{ icon: this.submenu.icon }}
.actionHandler=${actionHandler({
// Need to allow event to propagate upwards, as it's caught by the
// <ha-button-menu> trigger slot to open/close the menu. Further
// propagation is forbidden by the @click handler on
// <ha-button-menu>.
allowPropagation: true,
hasHold: hasAction(this.submenu.hold_action),
hasDoubleClick: hasAction(this.submenu.double_tap_action),
})}
></advanced-camera-card-icon>
</ha-icon-button>
</advanced-camera-card-submenu>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(menuButtonStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-submenu-button': AdvancedCameraCardSubmenuButton;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Interaction } from '../../types';
export interface SubmenuItem {
title?: string;
subtitle?: string;
icon?: string;
entity?: string;
style?: Record<string, string>;
enabled?: boolean;
selected?: boolean;
hold_action?: unknown;
double_tap_action?: unknown;
[key: string]: unknown;
}
export interface SubmenuInteraction extends Interaction {
item: SubmenuItem;
}
-9
View File
@@ -23,18 +23,12 @@ export class AdvancedCameraCardSurroundBasic extends LitElement {
protected _refDrawerRight: Ref<AdvancedCameraCardDrawer> = createRef(); protected _refDrawerRight: Ref<AdvancedCameraCardDrawer> = createRef();
protected _boundDrawerHandler = this._drawerHandler.bind(this); protected _boundDrawerHandler = this._drawerHandler.bind(this);
/**
* Component connected callback.
*/
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this.addEventListener('advanced-camera-card:drawer:open', this._boundDrawerHandler); this.addEventListener('advanced-camera-card:drawer:open', this._boundDrawerHandler);
this.addEventListener('advanced-camera-card:drawer:close', this._boundDrawerHandler); this.addEventListener('advanced-camera-card:drawer:close', this._boundDrawerHandler);
} }
/**
* Component disconnected callback.
*/
disconnectedCallback(): void { disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this.removeEventListener( this.removeEventListener(
@@ -77,9 +71,6 @@ export class AdvancedCameraCardSurroundBasic extends LitElement {
<slot name="below"></slot>`; <slot name="below"></slot>`;
} }
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(surroundBasicStyle); return unsafeCSS(surroundBasicStyle);
} }
+1
View File
@@ -396,6 +396,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
</advanced-camera-card-carousel> </advanced-camera-card-carousel>
${view ${view
? html` <advanced-camera-card-ptz ? html` <advanced-camera-card-ptz
.hass=${this.hass}
.config=${this.viewerConfig?.controls.ptz} .config=${this.viewerConfig?.controls.ptz}
.forceVisibility=${view?.context?.ptzControls?.enabled} .forceVisibility=${view?.context?.ptzControls?.enabled}
> >
+1
View File
@@ -618,6 +618,7 @@
"down": "Avall", "down": "Avall",
"home": "Casa", "home": "Casa",
"left": "Esquerra", "left": "Esquerra",
"presets": "",
"right": "Dreta", "right": "Dreta",
"up": "Amunt", "up": "Amunt",
"zoom_in": "Ampliar", "zoom_in": "Ampliar",
+2 -1
View File
@@ -317,7 +317,7 @@
"editor_label": "Live Controls", "editor_label": "Live Controls",
"ptz": { "ptz": {
"editor_label": "PTZ", "editor_label": "PTZ",
"hide_home": "Hide home control", "hide_home": "Hide home & preset controls",
"hide_pan_tilt": "Hide pan & tilt control", "hide_pan_tilt": "Hide pan & tilt control",
"hide_zoom": "Hide zoom control", "hide_zoom": "Hide zoom control",
"mode": "Mode", "mode": "Mode",
@@ -618,6 +618,7 @@
"down": "Down", "down": "Down",
"home": "Home", "home": "Home",
"left": "Left", "left": "Left",
"presets": "Presets",
"right": "Right", "right": "Right",
"up": "Up", "up": "Up",
"zoom_in": "Zoom In", "zoom_in": "Zoom In",
+1
View File
@@ -618,6 +618,7 @@
"down": "Bas", "down": "Bas",
"home": "Origine", "home": "Origine",
"left": "Gauche", "left": "Gauche",
"presets": "",
"right": "Droite", "right": "Droite",
"up": "Haut", "up": "Haut",
"zoom_in": "Zoomer", "zoom_in": "Zoomer",
+1
View File
@@ -618,6 +618,7 @@
"down": "Giù", "down": "Giù",
"home": "Home", "home": "Home",
"left": "Sinistra", "left": "Sinistra",
"presets": "",
"right": "Destra", "right": "Destra",
"up": "Su", "up": "Su",
"zoom_in": "Ingrandire", "zoom_in": "Ingrandire",
+1
View File
@@ -618,6 +618,7 @@
"down": "Baixo", "down": "Baixo",
"home": "Casa", "home": "Casa",
"left": "Esquerda", "left": "Esquerda",
"presets": "",
"right": "Direita", "right": "Direita",
"up": "Cima", "up": "Cima",
"zoom_in": "Aumentar Zoom", "zoom_in": "Aumentar Zoom",
+1
View File
@@ -618,6 +618,7 @@
"down": "Baixo", "down": "Baixo",
"home": "Origem", "home": "Origem",
"left": "Esquerda", "left": "Esquerda",
"presets": "",
"right": "Direira", "right": "Direira",
"up": "Cima", "up": "Cima",
"zoom_in": "Ampliar", "zoom_in": "Ampliar",
+13 -43
View File
@@ -1,4 +1,5 @@
@use './themes/base.scss'; @use './themes/base.scss';
@import './z-index.scss';
:host { :host {
display: block; display: block;
@@ -10,9 +11,8 @@
// this ensures the same experience across all browsers. // this ensures the same experience across all browsers.
background-color: var(--card-background-color); 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); border-radius: var(--ha-card-border-radius, 4px);
overflow: auto;
height: var(--advanced-camera-card-height); height: var(--advanced-camera-card-height);
min-height: 100px; min-height: 100px;
@@ -32,10 +32,18 @@
--advanced-camera-card-height: auto; --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 { advanced-camera-card-loading {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: #{$z-index-loading};
} }
:host([dimmable]:not([interaction])) { :host([dimmable]:not([interaction])) {
@@ -60,17 +68,11 @@ advanced-camera-card-loading {
div.main { div.main {
position: relative; position: relative;
// Required to keep curved corners on the card.
overflow: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: auto; margin: auto;
display: block; display: block;
// Necessary to get Safari to show border-radius correctly.
transform: translateZ(0);
// Hide scrollbar: Firefox // Hide scrollbar: Firefox
scrollbar-width: none; scrollbar-width: none;
// Hide scrollbar: IE and Edge // Hide scrollbar: IE and Edge
@@ -82,20 +84,6 @@ div.main::-webkit-scrollbar {
display: none; 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 { ha-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -181,30 +169,12 @@ web-dialog::part(dialog) {
background: transparent; 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 * Menu hover styles
*******************/ *******************/
advanced-camera-card-menu { advanced-camera-card-menu {
z-index: 2; z-index: #{$z-index-menu};
} }
advanced-camera-card-menu[data-style*='hover'] { advanced-camera-card-menu[data-style*='hover'] {
@@ -223,7 +193,7 @@ ha-card:hover
*************************/ *************************/
advanced-camera-card-status-bar { advanced-camera-card-status-bar {
z-index: 1; z-index: #{$z-index-status-bar};
} }
advanced-camera-card-status-bar[data-style*='hover'] { advanced-camera-card-status-bar[data-style*='hover'] {
+3 -1
View File
@@ -1,3 +1,5 @@
@import './z-index.scss';
:host { :host {
// Drawer width sizes to contents. // Drawer width sizes to contents.
width: unset; width: unset;
@@ -28,7 +30,7 @@
// Drawer renders behind the menu/status-bar overlay (otherwise the drawer // Drawer renders behind the menu/status-bar overlay (otherwise the drawer
// controls render on top of menu items) // controls render on top of menu items)
z-index: 10; z-index: #{$z-index-drawer};
} }
:host([location='right']) #d { :host([location='right']) #d {
-8
View File
@@ -9,7 +9,6 @@ div.control-surround {
position: absolute; position: absolute;
bottom: 50%; bottom: 50%;
transform: translateY(50%); transform: translateY(50%);
z-index: 0;
padding-top: $drawer-padding-extend; padding-top: $drawer-padding-extend;
padding-bottom: $drawer-padding-extend; padding-bottom: $drawer-padding-extend;
} }
@@ -48,13 +47,6 @@ advanced-camera-card-icon.control {
transition: opacity 0.5s ease; 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 { :host([location='left']) advanced-camera-card-icon.control {
border-top-right-radius: $drawer-icon-size; border-top-right-radius: $drawer-icon-size;
border-bottom-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size;
-4
View File
@@ -43,10 +43,6 @@
var(--advanced-camera-card-grid-column-size) 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 { slot {
+12
View File
@@ -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);
}
+1 -12
View File
@@ -1,4 +1,4 @@
@use './button.scss'; @use './menu-button.scss';
:host { :host {
--advanced-camera-card-menu-button-size: 40px; --advanced-camera-card-menu-button-size: 40px;
@@ -10,9 +10,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
// Allow submenus to overflow the menu "bar".
overflow: visible;
} }
:host([data-style='outside']) { :host([data-style='outside']) {
@@ -142,13 +139,5 @@ div.opposing {
background: var(--advanced-camera-card-menu-background); 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 // Further theme related styling is dynamically applied by `menu.ts`, see
// `_renderPerInstanceStyle`. // `_renderPerInstanceStyle`.
-28
View File
@@ -55,31 +55,3 @@ slot[name='bottom'],
slot[name='right'] { slot[name='right'] {
justify-content: flex-end; 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);
}
+18 -34
View File
@@ -55,7 +55,7 @@
.ptz-move, .ptz-move,
.ptz-zoom, .ptz-zoom,
.ptz-home { .ptz-presets {
position: relative; position: relative;
background-color: rgba(0, 0, 0, 0.3); 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-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); width: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5);
} }
:host([data-orientation='vertical']) .ptz .ptz-zoom, :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); height: calc(var(--advanced-camera-card-ptz-icon-size) * 1.5);
} }
.ptz-zoom, .ptz-zoom,
.ptz-home { .ptz-presets {
border-radius: var(--ha-card-border-radius, 4px); border-radius: var(--ha-card-border-radius, 4px);
} }
/*********** /***********
* PTZ Icons * PTZ Icons
***********/ ***********/
advanced-camera-card-icon { .ptz-move advanced-camera-card-icon {
position: absolute; position: absolute;
--mdc-icon-size: var(--advanced-camera-card-ptz-icon-size); --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; cursor: pointer;
} }
.disabled { .disabled {
@@ -115,34 +116,17 @@ advanced-camera-card-icon:not(.disabled) {
transform: translateY(-50%); transform: translateY(-50%);
} }
:host([data-orientation='vertical']) .zoom_in { .ptz-presets,
right: 5px; .ptz-zoom {
top: 50%; display: flex;
align-items: center;
justify-content: space-evenly;
} }
:host([data-orientation='vertical']) .zoom_out { :host([data-orientation='vertical']) .ptz-presets,
left: 5px; :host([data-orientation='vertical']) .ptz-zoom {
top: 50%; flex-direction: row;
} }
:host([data-orientation='horizontal']) .zoom_in { :host([data-orientation='horizontal']) .ptz-presets,
left: 50%; :host([data-orientation='horizontal']) .ptz-zoom {
top: 5px; flex-direction: column;
}
: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%);
} }
+2 -3
View File
@@ -1,11 +1,10 @@
@use './button.scss'; @use './button.scss';
@import './z-index.scss';
:host { :host {
pointer-events: auto; pointer-events: auto;
}
mwc-list-item { --mdc-menu-z-index: #{$z-index-submenu};
z-index: 20;
} }
ha-icon-button { ha-icon-button {
+22
View File
@@ -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;
+4
View File
@@ -153,6 +153,10 @@ export interface Icon {
fallback?: string; fallback?: string;
} }
export interface Interaction {
action: string;
}
// ************************************************************************* // *************************************************************************
// Home Assistant API types. // Home Assistant API types.
// ************************************************************************* // *************************************************************************
+10 -2
View File
@@ -9,7 +9,7 @@ import { PTZAction } from '../config/ptz.js';
import { import {
ActionPhase, ActionPhase,
ActionType, ActionType,
Actions, ActionsConfig,
AdvancedCameraCardGeneralAction, AdvancedCameraCardGeneralAction,
AdvancedCameraCardUserSpecifiedView, AdvancedCameraCardUserSpecifiedView,
CameraSelectActionConfig, CameraSelectActionConfig,
@@ -220,6 +220,7 @@ export function createInternalCallbackAction(
export function createPerformAction( export function createPerformAction(
perform_action: string, perform_action: string,
options?: { options?: {
cardID?: string;
data?: ServiceCallRequest['serviceData']; data?: ServiceCallRequest['serviceData'];
target?: ServiceCallRequest['target']; target?: ServiceCallRequest['target'];
}, },
@@ -229,6 +230,7 @@ export function createPerformAction(
perform_action: perform_action, perform_action: perform_action,
...(options?.target && { target: options.target }), ...(options?.target && { target: options.target }),
...(options?.data && { data: options.data }), ...(options?.data && { data: options.data }),
...(options?.cardID && { card_id: options.cardID }),
}; };
} }
@@ -240,13 +242,19 @@ export function createPerformAction(
*/ */
export function getActionConfigGivenAction( export function getActionConfigGivenAction(
interaction?: string, interaction?: string,
config?: Actions | null, config?: ActionsConfig | null,
): ActionType | ActionType[] | null { ): ActionType | ActionType[] | null {
if (!interaction || !config) { if (!interaction || !config) {
return null; return null;
} }
if (interaction === 'tap' && config.tap_action) { if (interaction === 'tap' && config.tap_action) {
return 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) { } else if (interaction === 'hold' && config.hold_action) {
return config.hold_action; return config.hold_action;
} else if (interaction === 'double_tap' && config.double_tap_action) { } else if (interaction === 'double_tap' && config.double_tap_action) {
+20 -10
View File
@@ -1,8 +1,13 @@
import { handleActionConfig } from '@dermotduffy/custom-card-helpers'; import { handleActionConfig } from '@dermotduffy/custom-card-helpers';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MenuController } from '../../src/components-lib/menu-controller'; import { MenuController } from '../../src/components-lib/menu-controller';
import { SubmenuItem } from '../../src/components/submenu/types';
import { MenuConfig, menuConfigSchema } from '../../src/config/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('@dermotduffy/custom-card-helpers');
vi.mock('../../src/utils/ha'); vi.mock('../../src/utils/ha');
@@ -365,7 +370,7 @@ describe('MenuController', () => {
describe('should handle actions', () => { describe('should handle actions', () => {
it('should bail without config', () => { it('should bail without config', () => {
const controller = new MenuController(createLitElement()); const controller = new MenuController(createLitElement());
controller.actionHandler(createInteractionEvent('tap')); controller.handleAction(createInteractionActionEvent('tap'));
expect(vi.mocked(handleActionConfig)).not.toBeCalled(); expect(vi.mocked(handleActionConfig)).not.toBeCalled();
}); });
@@ -376,7 +381,7 @@ describe('MenuController', () => {
const controller = new MenuController(host); const controller = new MenuController(host);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action], config: tapActionConfig }, detail: { action: [action], config: tapActionConfig },
@@ -392,7 +397,9 @@ describe('MenuController', () => {
const controller = new MenuController(host); const controller = new MenuController(host);
controller.actionHandler(createInteractionEvent('tap', tapActionConfig)); controller.handleAction(
createSubmenuInteractionActionEvent('tap', tapActionConfig as SubmenuItem),
);
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
detail: { action: [action], config: tapActionConfig }, detail: { action: [action], config: tapActionConfig },
@@ -407,7 +414,7 @@ describe('MenuController', () => {
const controller = new MenuController(host); const controller = new MenuController(host);
controller.actionHandler(createInteractionEvent('tap'), tapActionConfigMulti); controller.handleAction(createInteractionActionEvent('tap'), tapActionConfigMulti);
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -429,7 +436,7 @@ describe('MenuController', () => {
controller.setExpanded(true); controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy(); expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); controller.handleAction(createInteractionActionEvent('tap'), tapActionConfig);
expect(controller.isExpanded()).toBeFalsy(); expect(controller.isExpanded()).toBeFalsy();
}); });
@@ -445,7 +452,7 @@ describe('MenuController', () => {
controller.setExpanded(true); controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy(); expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createInteractionEvent('end_tap'), { controller.handleAction(createInteractionActionEvent('end_tap'), {
end_tap_action: action, end_tap_action: action,
}); });
expect(controller.isExpanded()).toBeFalsy(); expect(controller.isExpanded()).toBeFalsy();
@@ -465,7 +472,7 @@ describe('MenuController', () => {
controller.setExpanded(true); controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy(); expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createInteractionEvent('start_tap'), { controller.handleAction(createInteractionActionEvent('start_tap'), {
start_tap_action: action, start_tap_action: action,
end_tap_action: action, end_tap_action: action,
}); });
@@ -484,7 +491,7 @@ describe('MenuController', () => {
controller.setExpanded(false); controller.setExpanded(false);
expect(controller.isExpanded()).toBeFalsy(); expect(controller.isExpanded()).toBeFalsy();
controller.actionHandler(createInteractionEvent('tap'), { controller.handleAction(createInteractionActionEvent('tap'), {
camera_entity: 'foo', camera_entity: 'foo',
tap_action: menuToggleAction, tap_action: menuToggleAction,
}); });
@@ -503,7 +510,10 @@ describe('MenuController', () => {
controller.setExpanded(true); controller.setExpanded(true);
expect(controller.isExpanded()).toBeTruthy(); expect(controller.isExpanded()).toBeTruthy();
controller.actionHandler(createInteractionEvent('end_tap'), tapActionConfig); controller.handleAction(
createInteractionActionEvent('end_tap'),
tapActionConfig,
);
expect(controller.isExpanded()).toBeTruthy(); expect(controller.isExpanded()).toBeTruthy();
}); });
}); });
+143 -99
View File
@@ -200,6 +200,7 @@ describe('PTZController', () => {
down: ['relative'], down: ['relative'],
zoomIn: ['relative'], zoomIn: ['relative'],
zoomOut: ['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', () => { describe('should handle action', () => {
@@ -276,103 +419,4 @@ describe('PTZController', () => {
expect(handler).not.toBeCalled(); 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,
});
});
});
}); });
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { StatusBarController } from '../../src/components-lib/status-bar-controller'; import { StatusBarController } from '../../src/components-lib/status-bar-controller';
import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types'; import { StatusBarConfig, statusBarConfigSchema } from '../../src/config/types';
import { setOrRemoveAttribute } from '../../src/utils/basic'; import { setOrRemoveAttribute } from '../../src/utils/basic';
import { createInteractionEvent, createLitElement } from '../test-utils'; import { createInteractionActionEvent, createLitElement } from '../test-utils';
const createConfig = (config?: unknown): StatusBarConfig => { const createConfig = (config?: unknown): StatusBarConfig => {
return statusBarConfigSchema.parse(config); return statusBarConfigSchema.parse(config);
@@ -300,7 +300,7 @@ describe('StatusBarController', () => {
host.addEventListener('advanced-camera-card:action:execution-request', handler); host.addEventListener('advanced-camera-card:action:execution-request', handler);
const controller = new StatusBarController(host); const controller = new StatusBarController(host);
controller.actionHandler(createInteractionEvent('tap')); controller.actionHandler(createInteractionActionEvent('tap'));
expect(handler).not.toBeCalled(); expect(handler).not.toBeCalled();
}); });
@@ -318,7 +318,7 @@ describe('StatusBarController', () => {
tap_action: action, tap_action: action,
}; };
controller.actionHandler(createInteractionEvent('tap'), tapActionConfig); controller.actionHandler(createInteractionActionEvent('tap'), tapActionConfig);
expect(handler).toBeCalledWith( expect(handler).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
+23 -8
View File
@@ -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 { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { LitElement } from 'lit'; import { LitElement } from 'lit';
import screenfull from 'screenfull'; 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 { StyleManager } from '../src/card-controller/style-manager';
import { TriggersManager } from '../src/card-controller/triggers-manager'; import { TriggersManager } from '../src/card-controller/triggers-manager';
import { ViewManager } from '../src/card-controller/view/view-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 { ConditionStateManager } from '../src/conditions/state-manager';
import { import {
ActionsConfig,
AdvancedCameraCardConfig, AdvancedCameraCardConfig,
CameraConfig, CameraConfig,
InternalAdvancedCameraCardCustomAction, InternalAdvancedCameraCardCustomAction,
@@ -52,7 +52,12 @@ import {
internalAdvancedCameraCardCustomActionSchema, internalAdvancedCameraCardCustomActionSchema,
performanceConfigSchema, performanceConfigSchema,
} from '../src/config/types'; } 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 { HassStateDifference } from '../src/utils/ha';
import { Device } from '../src/utils/ha/registry/device/types'; import { Device } from '../src/utils/ha/registry/device/types';
import { EntityRegistryManager } from '../src/utils/ha/registry/entity'; import { EntityRegistryManager } from '../src/utils/ha/registry/entity';
@@ -520,14 +525,24 @@ export const flushPromises = async (): Promise<void> => {
await new Promise(process.nextTick); await new Promise(process.nextTick);
}; };
export const createInteractionEvent = ( export const createInteractionActionEvent = (
action: string, action: string,
config?: ActionsConfig, ): CustomEvent<Interaction> => {
): HASSDomEvent<{ action: string; config?: ActionsConfig }> => { return new CustomEvent<Interaction>('@action', {
return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
detail: { detail: {
action: action, action: action,
config: config, },
});
};
export const createSubmenuInteractionActionEvent = (
action: string,
item: SubmenuItem,
): CustomEvent<SubmenuInteraction> => {
return new CustomEvent<SubmenuInteraction>('@action', {
detail: {
action,
item,
}, },
}); });
}; };
+31
View File
@@ -10,6 +10,7 @@ import {
createInternalCallbackAction, createInternalCallbackAction,
createLogAction, createLogAction,
createMediaPlayerAction, createMediaPlayerAction,
createPerformAction,
createPTZAction, createPTZAction,
createPTZControlsAction, createPTZControlsAction,
createPTZDigitalAction, 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', () => { describe('getActionConfigGivenAction', () => {
const action = actionSchema.parse({ const action = actionSchema.parse({
action: 'fire-dom-event', action: 'fire-dom-event',
@@ -293,6 +312,18 @@ describe('getActionConfigGivenAction', () => {
expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action); 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', () => { it('should handle hold actions', () => {
expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action); expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
}); });