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:
@@ -399,6 +399,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
${this._renderNextPrevious('right', neighbors)}
|
||||
</advanced-camera-card-carousel>
|
||||
<advanced-camera-card-ptz
|
||||
.hass=${this.hass}
|
||||
.config=${this.liveConfig.controls.ptz}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameraID=${getStreamCameraID(view, this.viewFilterCameraID)}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { hasAction } from '../utils/action.js';
|
||||
import { getEntityTitle } from '../utils/ha/index.js';
|
||||
import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js';
|
||||
import './icon.js';
|
||||
import './submenu.js';
|
||||
import './submenu/select-button.js';
|
||||
import './submenu/submenu-button';
|
||||
|
||||
@customElement('advanced-camera-card-menu')
|
||||
export class AdvancedCameraCardMenu extends LitElement {
|
||||
@@ -44,20 +45,20 @@ export class AdvancedCameraCardMenu extends LitElement {
|
||||
}
|
||||
|
||||
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}
|
||||
.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') {
|
||||
return html` <advanced-camera-card-submenu-select
|
||||
return html` <advanced-camera-card-submenu-select-button
|
||||
.hass=${this.hass}
|
||||
.submenuSelect=${button}
|
||||
.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 =
|
||||
@@ -73,7 +74,7 @@ export class AdvancedCameraCardMenu extends LitElement {
|
||||
hasDoubleClick: hasAction(button.double_tap_action),
|
||||
})}
|
||||
.label=${title ?? ''}
|
||||
@action=${(ev) => this._controller.actionHandler(ev, button)}
|
||||
@action=${(ev) => this._controller.handleAction(ev, button)}
|
||||
>
|
||||
<advanced-camera-card-icon
|
||||
?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 {
|
||||
CSSResultGroup,
|
||||
LitElement,
|
||||
@@ -12,15 +12,21 @@ import { classMap } from 'lit/directives/class-map.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import { CameraManager } from '../camera-manager/manager.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 { localize } from '../localize/localize.js';
|
||||
import ptzStyle from '../scss/ptz.scss';
|
||||
import { Interaction } from '../types.js';
|
||||
import { hasAction } from '../utils/action.js';
|
||||
import { prettifyTitle } from '../utils/basic.js';
|
||||
import './icon.js';
|
||||
import './submenu';
|
||||
import { SubmenuInteraction, SubmenuItem } from './submenu/types.js';
|
||||
|
||||
@customElement('advanced-camera-card-ptz')
|
||||
export class AdvancedCameraCardPTZ extends LitElement {
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: PTZControlsConfig;
|
||||
|
||||
@@ -34,8 +40,7 @@ export class AdvancedCameraCardPTZ extends LitElement {
|
||||
public forceVisibility?: boolean;
|
||||
|
||||
protected _controller = new PTZController(this);
|
||||
protected _actions = this._controller.getPTZActions();
|
||||
protected _actionPresence: PTZActionPresence | null = null;
|
||||
protected _actions: PTZControllerActions | null = null;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('config')) {
|
||||
@@ -48,7 +53,7 @@ export class AdvancedCameraCardPTZ extends LitElement {
|
||||
this._controller.setForceVisibility(this.forceVisibility);
|
||||
}
|
||||
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 = (
|
||||
name: string,
|
||||
icon: string,
|
||||
actions?: Actions | null,
|
||||
options?: {
|
||||
actions?: Actions | null;
|
||||
renderWithoutAction?: boolean;
|
||||
},
|
||||
): TemplateResult => {
|
||||
const classes = {
|
||||
[name]: true,
|
||||
disabled: !actions,
|
||||
disabled: !options?.actions && !options?.renderWithoutAction,
|
||||
};
|
||||
|
||||
return actions
|
||||
return options?.actions || options?.renderWithoutAction
|
||||
? html`<advanced-camera-card-icon
|
||||
class=${classMap(classes)}
|
||||
.icon=${{ icon: icon }}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(actions?.hold_action),
|
||||
hasDoubleClick: hasAction(actions?.double_tap_action),
|
||||
})}
|
||||
.title=${localize(`elements.ptz.${name}`)}
|
||||
@action=${(ev: HASSDomEvent<{ action: string }>) =>
|
||||
this._controller.handleAction(ev, actions)}
|
||||
.actionHandler=${options.actions
|
||||
? actionHandler({
|
||||
hasHold: hasAction(options.actions?.hold_action),
|
||||
hasDoubleClick: hasAction(options.actions?.double_tap_action),
|
||||
})
|
||||
: undefined}
|
||||
@action=${(ev: CustomEvent<Interaction>) =>
|
||||
options.actions && this._controller.handleAction(ev, options.actions)}
|
||||
></advanced-camera-card-icon>`
|
||||
: html``;
|
||||
};
|
||||
|
||||
const presetSubmenuItems: SubmenuItem[] | null = this._actions?.presets?.length
|
||||
? this._actions.presets.map((preset) => ({
|
||||
title: prettifyTitle(preset.preset),
|
||||
icon: 'mdi:cctv',
|
||||
...preset.actions,
|
||||
hold_action: {
|
||||
action: 'perform-action',
|
||||
perform_action: 'camera.preset_recall',
|
||||
},
|
||||
}))
|
||||
: null;
|
||||
|
||||
const config = this._controller.getConfig();
|
||||
return html` <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">
|
||||
${renderIcon('right', 'mdi:arrow-right', this._actions.right)}
|
||||
${renderIcon('left', 'mdi:arrow-left', this._actions.left)}
|
||||
${renderIcon('up', 'mdi:arrow-up', this._actions.up)}
|
||||
${renderIcon('down', 'mdi:arrow-down', this._actions.down)}
|
||||
${renderIcon('right', 'mdi:arrow-right', { actions: this._actions?.right })}
|
||||
${renderIcon('left', 'mdi:arrow-left', { actions: this._actions?.left })}
|
||||
${renderIcon('up', 'mdi:arrow-up', { actions: this._actions?.up })}
|
||||
${renderIcon('down', 'mdi:arrow-down', { actions: this._actions?.down })}
|
||||
</div>`
|
||||
: ''}
|
||||
${!config?.hide_zoom && this._actionPresence?.z
|
||||
${!config?.hide_zoom && (this._actions?.zoom_in || this._actions?.zoom_out)
|
||||
? html` <div class="ptz-zoom">
|
||||
${renderIcon('zoom_in', 'mdi:plus', this._actions.zoom_in)}
|
||||
${renderIcon('zoom_out', 'mdi:minus', this._actions.zoom_out)}
|
||||
${renderIcon('zoom_in', 'mdi:plus', { actions: this._actions.zoom_in })}
|
||||
${renderIcon('zoom_out', 'mdi:minus', { actions: this._actions.zoom_out })}
|
||||
</div>`
|
||||
: html``}
|
||||
${!config?.hide_home && this._actionPresence?.home
|
||||
? html`<div class="ptz-home">
|
||||
${renderIcon('home', 'mdi:home', this._actions.home)}
|
||||
${!config?.hide_home && (this._actions?.home || presetSubmenuItems?.length)
|
||||
? html`<div class="ptz-presets">
|
||||
${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>`
|
||||
: html``}
|
||||
: ''}
|
||||
</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 _boundDrawerHandler = this._drawerHandler.bind(this);
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('advanced-camera-card:drawer:open', this._boundDrawerHandler);
|
||||
this.addEventListener('advanced-camera-card:drawer:close', this._boundDrawerHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(
|
||||
@@ -77,9 +71,6 @@ export class AdvancedCameraCardSurroundBasic extends LitElement {
|
||||
<slot name="below"></slot>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundBasicStyle);
|
||||
}
|
||||
|
||||
@@ -396,6 +396,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
</advanced-camera-card-carousel>
|
||||
${view
|
||||
? html` <advanced-camera-card-ptz
|
||||
.hass=${this.hass}
|
||||
.config=${this.viewerConfig?.controls.ptz}
|
||||
.forceVisibility=${view?.context?.ptzControls?.enabled}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user