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:
@@ -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. |
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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'] {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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%);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user