Draws significant inspiration (and direct styling) from https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank you @Maudfer ! BREAKING CHANGE: The microphone condition previously bundled two unrelated signals — whether a two-way-audio session was connected and whether the microphone was muted. Connection state is now its own dedicated call condition, and microphone is reserved purely for mute state. Configs are upgraded automatically (the card rewrites affected conditions under overrides, elements, and automations). If you maintain config by hand, convert as follows: If you only used connected: # Before ```yaml condition: microphone connected: true ``` # After ```yaml condition: call call: true ``` If you used both connected and muted — they must be split into two conditions, since they no longer live together: # Before ```yaml condition: microphone connected: true muted: false ``` # After ```yaml condition: and conditions: - condition: call call: true - condition: microphone muted: false ```
198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
import { CSSResultGroup, LitElement, TemplateResult, html, 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 type { LockManagerEpoch } from '../card-controller/lock/types';
|
|
import type { AutoHideState } from '../components-lib/auto-hide.js';
|
|
import { MenuController } from '../components-lib/menu-controller.js';
|
|
import type { MenuItem } from '../config/schema/elements/custom/menu/types.js';
|
|
import type { MenuConfig } from '../config/schema/menu.js';
|
|
import { getEntityTitle } from '../ha/get-entity-title.js';
|
|
import type { EntityRegistryManager } from '../ha/registry/entity/types.js';
|
|
import type { HomeAssistant } from '../ha/types.js';
|
|
import menuStyle from '../scss/menu.scss';
|
|
import { hasAction } from '../utils/action.js';
|
|
import { contentsChanged } from '../utils/basic.js';
|
|
import './icon.js';
|
|
import './submenu/select-button.js';
|
|
import './submenu/submenu-button';
|
|
|
|
@customElement('advanced-camera-card-menu')
|
|
export class AdvancedCameraCardMenu extends LitElement {
|
|
private _controller = new MenuController(this);
|
|
|
|
@property({ attribute: false })
|
|
public entityRegistryManager?: EntityRegistryManager;
|
|
|
|
@property({ attribute: false })
|
|
public hass?: HomeAssistant;
|
|
|
|
@property({ attribute: false })
|
|
public lockManagerEpoch?: LockManagerEpoch;
|
|
|
|
@property({ attribute: false, hasChanged: contentsChanged })
|
|
public autoHideState?: AutoHideState;
|
|
|
|
set menuConfig(menuConfig: MenuConfig) {
|
|
this._controller.setMenuConfig(menuConfig);
|
|
}
|
|
|
|
set buttons(buttons: MenuItem[]) {
|
|
this._controller.setButtons(buttons);
|
|
}
|
|
|
|
set expanded(expanded: boolean) {
|
|
this._controller.setExpanded(expanded);
|
|
}
|
|
|
|
protected willUpdate(changedProps: Map<string, unknown>): void {
|
|
if (changedProps.has('lockManagerEpoch')) {
|
|
this._controller.setLockManagerEpoch(this.lockManagerEpoch);
|
|
}
|
|
if (changedProps.has('autoHideState') && this.autoHideState) {
|
|
this._controller.setAutoHideState(this.autoHideState);
|
|
}
|
|
}
|
|
|
|
public toggleMenu(): void {
|
|
this._controller.toggleExpanded();
|
|
}
|
|
|
|
private _renderButton(button: MenuItem): TemplateResult | void {
|
|
if (!this.hass) {
|
|
return;
|
|
}
|
|
|
|
if (button.type === 'custom:advanced-camera-card-menu-submenu') {
|
|
return html` <advanced-camera-card-submenu-button
|
|
.hass=${this.hass}
|
|
.submenu=${button}
|
|
.lockManagerEpoch=${this.lockManagerEpoch}
|
|
@action=${(ev) => this._controller.handleAction(ev, button)}
|
|
>
|
|
</advanced-camera-card-submenu-button>`;
|
|
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
|
|
return html` <advanced-camera-card-submenu-select-button
|
|
.hass=${this.hass}
|
|
.submenuSelect=${button}
|
|
.entityRegistryManager=${this.entityRegistryManager}
|
|
.lockManagerEpoch=${this.lockManagerEpoch}
|
|
@action=${(ev) => this._controller.handleAction(ev, button)}
|
|
>
|
|
</advanced-camera-card-submenu-select-button>`;
|
|
}
|
|
|
|
const title =
|
|
this.hass &&
|
|
button.type === 'custom:advanced-camera-card-menu-state-icon' &&
|
|
!button.title
|
|
? getEntityTitle(this.hass, button.entity)
|
|
: button.title;
|
|
|
|
return html` <ha-icon-button
|
|
.actionHandler=${actionHandler({
|
|
hasHold: hasAction(button.hold_action),
|
|
hasDoubleClick: hasAction(button.double_tap_action),
|
|
})}
|
|
.label=${title ?? ''}
|
|
?disabled=${this._controller.shouldButtonBeInert(button)}
|
|
@action=${(ev) => this._controller.handleAction(ev, button)}
|
|
>
|
|
<advanced-camera-card-icon
|
|
?allow-override-non-active-styles=${true}
|
|
style="${styleMap(button.style || {})}"
|
|
.hass=${this.hass}
|
|
.icon=${{
|
|
icon: button.icon,
|
|
entity: button.entity,
|
|
stateColor: button.state_color,
|
|
fallback: 'mdi:gesture-tap-button',
|
|
}}
|
|
></advanced-camera-card-icon>
|
|
</ha-icon-button>`;
|
|
}
|
|
|
|
/** Theme-related styling is dynamically injected into the menu depending on
|
|
* the configured position, style and alignment to allow precise theming.
|
|
* The alternative is a massive (post-sass processing) CSS file would need to
|
|
* be shipped to account for every possible combination.
|
|
*
|
|
* Each rule uses 'var' values that have nested fallbacks of decreasing
|
|
* specificity, so the most specific theme variable will match, followed by
|
|
* the next most specific, etc.
|
|
*/
|
|
private _renderPerInstanceStyle(): TemplateResult | void {
|
|
const config = this._controller.getMenuConfig();
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
const position = config.position;
|
|
const style = config.style;
|
|
const alignment = config.alignment;
|
|
|
|
const generateValue = (suffix: string): string => {
|
|
return `
|
|
var(--advanced-camera-card-menu-override-${suffix},
|
|
var(--advanced-camera-card-menu-position-${position}-alignment-${alignment}-style-${style}-${suffix},
|
|
var(--advanced-camera-card-menu-position-${position}-alignment-${alignment}-${suffix},
|
|
var(--advanced-camera-card-menu-position-${position}-${suffix},
|
|
var(--advanced-camera-card-menu-style-${style}-${suffix},
|
|
var(--advanced-camera-card-menu-alignment-${alignment}-${suffix},
|
|
var(--advanced-camera-card-menu-${suffix})))))))`;
|
|
};
|
|
|
|
// By definition `rule` will match the current configuration, the choice is
|
|
// actually which of the var(...) variables will be used after the match.
|
|
const expandedRule = style === 'hidden' ? '[expanded]' : '';
|
|
const rule =
|
|
`[data-position='${position}']` +
|
|
`[data-style='${style}']` +
|
|
`[data-alignment='${alignment}']` +
|
|
expandedRule;
|
|
|
|
return html`<style>
|
|
:host(${rule}) {
|
|
background: ${generateValue('background')};
|
|
|
|
ha-icon-button {
|
|
color: ${generateValue('button-inactive-color')};
|
|
background: ${generateValue('button-background')};
|
|
}
|
|
}
|
|
</style>`;
|
|
}
|
|
|
|
protected render(): TemplateResult | void {
|
|
if (!this._controller.shouldRender()) {
|
|
return;
|
|
}
|
|
const matchingButtons = this._controller.getButtons('matching');
|
|
const opposingButtons = this._controller.getButtons('opposing');
|
|
|
|
return html` ${this._renderPerInstanceStyle()}
|
|
<div
|
|
class="matching"
|
|
style="${styleMap({ flex: String(matchingButtons.length) })}"
|
|
>
|
|
${matchingButtons.map((button) => this._renderButton(button))}
|
|
</div>
|
|
<div
|
|
class="opposing"
|
|
style="${styleMap({ flex: String(opposingButtons.length) })}"
|
|
>
|
|
${opposingButtons.map((button) => this._renderButton(button))}
|
|
</div>`;
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return unsafeCSS(menuStyle);
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'advanced-camera-card-menu': AdvancedCameraCardMenu;
|
|
}
|
|
}
|