fix: Restore picture element actions on Home Assistant 2026.8+ (#2670)
- Closes: #2663
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
import { errorToConsole } from '../utils/basic';
|
||||||
|
|
||||||
|
// A picture element that expects the container rendering it to dispatch its
|
||||||
|
// actions for it.
|
||||||
|
interface ActionDelegatingElement extends HTMLElement {
|
||||||
|
delegatedActions: boolean;
|
||||||
|
requestUpdate: (name?: PropertyKey, oldValue?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActionDelegatingElement = (node: Node): node is ActionDelegatingElement =>
|
||||||
|
node instanceof HTMLElement &&
|
||||||
|
'delegatedActions' in node &&
|
||||||
|
node.delegatedActions === true &&
|
||||||
|
'requestUpdate' in node &&
|
||||||
|
typeof node.requestUpdate === 'function';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home Assistant picture elements of some types (e.g. `icon`, `state-icon`) do
|
||||||
|
* not listen for taps themselves. They expect the container rendering them to
|
||||||
|
* work out which element a tap belongs to and to dispatch the action on their
|
||||||
|
* behalf, so that a tap landing between two elements still reaches the nearest
|
||||||
|
* one. This card is *not* such a container: taps that miss an element need to
|
||||||
|
* pass through its elements overlay to the media beneath, so it only ever sees
|
||||||
|
* a tap that landed on an element. This tells each element to listen for its
|
||||||
|
* own taps instead.
|
||||||
|
*/
|
||||||
|
export class ElementActionsController {
|
||||||
|
private _observer = new MutationObserver(this._handleMutations.bind(this));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the tree of elements that should listen for their own taps. Elements
|
||||||
|
* that join the tree later (e.g. those of a conditional element whose
|
||||||
|
* conditions become true) are handled as they arrive.
|
||||||
|
* @param root The root of the element tree.
|
||||||
|
*/
|
||||||
|
public setRoot(root: Element): void {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer.observe(root, { childList: true, subtree: true });
|
||||||
|
this._stopDelegationInSubtree(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMutations(mutations: MutationRecord[]): void {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
this._stopDelegationInSubtree(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopDelegationInSubtree(node: Node): void {
|
||||||
|
this._stopDelegation(node);
|
||||||
|
|
||||||
|
if (node instanceof Element) {
|
||||||
|
for (const descendant of node.querySelectorAll('*')) {
|
||||||
|
this._stopDelegation(descendant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopDelegation(node: Node): void {
|
||||||
|
if (!isActionDelegatingElement(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
node.delegatedActions = false;
|
||||||
|
|
||||||
|
// The element decides whether to listen for taps as it renders, so it
|
||||||
|
// must render again for the change to take effect. Some element types
|
||||||
|
// (e.g. icons) do not render for every Home Assistant they are given,
|
||||||
|
// only when something they read out of it changed, so naming Home
|
||||||
|
// Assistant may not be sufficient to trigger a re-render. Naming the
|
||||||
|
// configuration (as it is named in stock HA elements) is the one they
|
||||||
|
// always act on; Home Assistant is a fallback should the configuration
|
||||||
|
// ever be named something else.
|
||||||
|
node.requestUpdate('_config', undefined);
|
||||||
|
node.requestUpdate('hass', undefined);
|
||||||
|
} catch (e) {
|
||||||
|
errorToConsole(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { isEqual } from 'lodash-es';
|
|||||||
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||||
import type { TemplateRenderer } from '../card-controller/templates/index.js';
|
import type { TemplateRenderer } from '../card-controller/templates/index.js';
|
||||||
import { getTemplateRendererViaEvent } from '../card-controller/templates/renderer-via-event.js';
|
import { getTemplateRendererViaEvent } from '../card-controller/templates/renderer-via-event.js';
|
||||||
|
import { ElementActionsController } from '../components-lib/element-actions-controller.js';
|
||||||
import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js';
|
import { ConditionsManager } from '../condition-trigger/conditions/conditions-manager.js';
|
||||||
import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js';
|
import { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js';
|
||||||
import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js';
|
import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js';
|
||||||
@@ -102,6 +103,8 @@ export class AdvancedCameraCardElementsCore extends LitElement {
|
|||||||
|
|
||||||
private _renderedElements?: PictureElements;
|
private _renderedElements?: PictureElements;
|
||||||
|
|
||||||
|
private _actionsController = new ElementActionsController();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a transparent render root.
|
* Create a transparent render root.
|
||||||
*/
|
*/
|
||||||
@@ -156,6 +159,7 @@ export class AdvancedCameraCardElementsCore extends LitElement {
|
|||||||
try {
|
try {
|
||||||
this._renderedElements = elements;
|
this._renderedElements = elements;
|
||||||
this._root = this._createRoot();
|
this._root = this._createRoot();
|
||||||
|
this._actionsController.setRoot(this._root);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToConsole(e);
|
errorToConsole(e);
|
||||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
|
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
|
||||||
|
|||||||
@@ -153,6 +153,22 @@ const isConfigurable = (element: HTMLElement): element is ConfigurableElement =>
|
|||||||
const CUSTOM_ELEMENT_PREFIX = 'custom:';
|
const CUSTOM_ELEMENT_PREFIX = 'custom:';
|
||||||
const CARD_ELEMENT_PREFIX = `${CUSTOM_ELEMENT_PREFIX}advanced-camera-card-`;
|
const CARD_ELEMENT_PREFIX = `${CUSTOM_ELEMENT_PREFIX}advanced-camera-card-`;
|
||||||
|
|
||||||
|
// The element types Home Assistant marks as not listening for taps themselves.
|
||||||
|
const ACTION_DELEGATING_TYPES = ['icon', 'state-badge', 'state-icon', 'state-label'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create one of Home Assistant's own picture elements, which it names after the
|
||||||
|
* configured type and marks with the class it positions elements by.
|
||||||
|
*/
|
||||||
|
const createHAElement = (type: string): HTMLElement => {
|
||||||
|
const element = Object.assign(document.createElement(`hui-${type}-element`), {
|
||||||
|
delegatedActions: ACTION_DELEGATING_TYPES.includes(type),
|
||||||
|
requestUpdate: () => {},
|
||||||
|
});
|
||||||
|
element.classList.add('element');
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home Assistant's conditional picture element, which the card builds one of on
|
* Home Assistant's conditional picture element, which the card builds one of on
|
||||||
* every mount to host whatever picture elements are configured.
|
* every mount to host whatever picture elements are configured.
|
||||||
@@ -161,9 +177,8 @@ const CARD_ELEMENT_PREFIX = `${CUSTOM_ELEMENT_PREFIX}advanced-camera-card-`;
|
|||||||
* bar items are elements that ask to be added when they are connected, so
|
* bar items are elements that ask to be added when they are connected, so
|
||||||
* without that a configured menu button never reaches the menu.
|
* without that a configured menu button never reaches the menu.
|
||||||
*
|
*
|
||||||
* Only the card's own elements are created here. Other elements (e.g. Home
|
* Home Assistant's own elements are created as empty stand-ins: they carry what
|
||||||
* Assistant's `icon` or `image`) are skipped, so a test that needs one must add
|
* Home Assistant puts on them, but render nothing.
|
||||||
* it to this stub first.
|
|
||||||
*/
|
*/
|
||||||
class HuiConditionalElementStub extends HTMLElement {
|
class HuiConditionalElementStub extends HTMLElement {
|
||||||
public hass?: unknown;
|
public hass?: unknown;
|
||||||
@@ -172,14 +187,13 @@ class HuiConditionalElementStub extends HTMLElement {
|
|||||||
this.replaceChildren();
|
this.replaceChildren();
|
||||||
|
|
||||||
for (const element of config.elements ?? []) {
|
for (const element of config.elements ?? []) {
|
||||||
if (!element.type.startsWith(CARD_ELEMENT_PREFIX)) {
|
const child = element.type.startsWith(CARD_ELEMENT_PREFIX)
|
||||||
continue;
|
? document.createElement(
|
||||||
}
|
// Example: custom:advanced-camera-card-menu-icon -> advanced-camera-card-menu-icon
|
||||||
|
element.type.slice(CUSTOM_ELEMENT_PREFIX.length),
|
||||||
|
)
|
||||||
|
: createHAElement(element.type);
|
||||||
|
|
||||||
const child = document.createElement(
|
|
||||||
// Example: custom:advanced-camera-card-menu-icon -> advanced-camera-card-menu-icon
|
|
||||||
element.type.slice(CUSTOM_ELEMENT_PREFIX.length),
|
|
||||||
);
|
|
||||||
if (isConfigurable(child)) {
|
if (isConfigurable(child)) {
|
||||||
child.setConfig(element);
|
child.setConfig(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { html, LitElement, type PropertyValues, type TemplateResult } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ElementActionsController } from '../../src/components-lib/element-actions-controller';
|
||||||
|
import { flushPromises } from '../test-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An element shaped like Home Assistant's `state-icon`, which renders again
|
||||||
|
* only when told that the configuration it was given changed, or that it has
|
||||||
|
* been given Home Assistant for the first time. It records whether it was
|
||||||
|
* delegating its actions each time it rendered.
|
||||||
|
*/
|
||||||
|
class RenderGatingElement extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
public hass?: object;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private _config?: object;
|
||||||
|
|
||||||
|
public delegatedActions = true;
|
||||||
|
public delegatedActionsWhenRendered: boolean[] = [];
|
||||||
|
|
||||||
|
public setConfig(config: object): void {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||||
|
return (
|
||||||
|
changedProps.has('_config') ||
|
||||||
|
(changedProps.has('hass') && !changedProps.get('hass'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
if (this._config) {
|
||||||
|
this.delegatedActionsWhenRendered.push(this.delegatedActions);
|
||||||
|
}
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('render-gating-test-element', RenderGatingElement);
|
||||||
|
|
||||||
|
const createDelegatingElement = () =>
|
||||||
|
Object.assign(document.createElement('div'), {
|
||||||
|
delegatedActions: true,
|
||||||
|
requestUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSelfHandlingElement = () =>
|
||||||
|
Object.assign(document.createElement('div'), {
|
||||||
|
delegatedActions: false,
|
||||||
|
requestUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('ElementActionsController', () => {
|
||||||
|
describe('should handle a tree that is already built', () => {
|
||||||
|
it('should stop an element delegating its actions', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
root.appendChild(element);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop a nested element delegating its actions', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const conditional = document.createElement('div');
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
conditional.appendChild(element);
|
||||||
|
root.appendChild(conditional);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop the root itself delegating its actions', () => {
|
||||||
|
const root = createDelegatingElement();
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(root.delegatedActions).toBe(false);
|
||||||
|
expect(root.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave an element alone when it handles its own actions', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const element = createSelfHandlingElement();
|
||||||
|
root.appendChild(element);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave an element alone when Home Assistant does not delegate its actions', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const element = Object.assign(document.createElement('div'), {
|
||||||
|
requestUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
root.appendChild(element);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue to handle the tree when an element throws', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const refusing = Object.assign(document.createElement('div'), {
|
||||||
|
delegatedActions: true,
|
||||||
|
requestUpdate: () => {
|
||||||
|
throw new Error('refused');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
root.append(refusing, element);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave an element alone when it cannot be asked to render again', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
|
||||||
|
// An element that never renders again keeps whatever it bound at its last
|
||||||
|
// render, so there is nothing to gain by changing it. 'div' has no
|
||||||
|
// 'requestUpdate' method.
|
||||||
|
const element = Object.assign(document.createElement('div'), {
|
||||||
|
delegatedActions: true,
|
||||||
|
});
|
||||||
|
root.appendChild(element);
|
||||||
|
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should handle a tree that changes', () => {
|
||||||
|
it('should stop an element added later from delegating its actions', async () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
root.appendChild(element);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop a nested element added later delegating its actions', async () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
const conditional = document.createElement('div');
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
conditional.appendChild(element);
|
||||||
|
root.appendChild(conditional);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore text added later', async () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
root.appendChild(document.createTextNode('text'));
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
root.appendChild(element);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore a tree that has been replaced', async () => {
|
||||||
|
const replacedRoot = document.createElement('div');
|
||||||
|
const controller = new ElementActionsController();
|
||||||
|
controller.setRoot(replacedRoot);
|
||||||
|
controller.setRoot(document.createElement('div'));
|
||||||
|
|
||||||
|
const element = createDelegatingElement();
|
||||||
|
replacedRoot.appendChild(element);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(true);
|
||||||
|
expect(element.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should force an element to re-render even if it gates rendering', async () => {
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
document.body.appendChild(holder);
|
||||||
|
onTestFinished(() => holder.remove());
|
||||||
|
|
||||||
|
const element = new RenderGatingElement();
|
||||||
|
|
||||||
|
element.setConfig({});
|
||||||
|
element.hass = { name: 'first' };
|
||||||
|
holder.appendChild(element);
|
||||||
|
await element.updateComplete;
|
||||||
|
|
||||||
|
expect(element.delegatedActionsWhenRendered).toEqual([true]);
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
new ElementActionsController().setRoot(root);
|
||||||
|
|
||||||
|
// The order matters: the element joins the tree, and is given a new Home
|
||||||
|
// Assistant only afterwards. It is then already waiting to render, holding
|
||||||
|
// the Home Assistant it had before to compare against, and would find
|
||||||
|
// nothing changed were it asked to render by naming Home Assistant alone.
|
||||||
|
// This tests that the '_config' request is what triggers the re-render.
|
||||||
|
root.appendChild(element);
|
||||||
|
element.hass = { name: 'second' };
|
||||||
|
await element.updateComplete;
|
||||||
|
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
expect(element.delegatedActionsWhenRendered).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'render-gating-test-element': RenderGatingElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { MountedCardFactory } from '../browser/mounted-card';
|
||||||
|
import {
|
||||||
|
createGenericCameraHASS,
|
||||||
|
createStillImageCardConfig,
|
||||||
|
} from '../browser/test-utils';
|
||||||
|
|
||||||
|
interface PictureElement extends Element {
|
||||||
|
delegatedActions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdvancedCameraCardElements', () => {
|
||||||
|
it('should have a picture element listen for its own taps', async () => {
|
||||||
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2664
|
||||||
|
const card = await MountedCardFactory.createFromSource(
|
||||||
|
createStillImageCardConfig({
|
||||||
|
elements: [
|
||||||
|
{ type: 'icon', icon: 'mdi:cow', tap_action: { action: 'more-info' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createGenericCameraHASS(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const element = await card.waitForSelector<PictureElement>('hui-icon-element');
|
||||||
|
|
||||||
|
// Home Assistant has this element wait for the container rendering it to
|
||||||
|
// dispatch the action for it. This card cannot do that as we require
|
||||||
|
// "behind" the elements to be able to receive pointer interactions.
|
||||||
|
expect(element.delegatedActions).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user