diff --git a/src/components-lib/element-actions-controller.ts b/src/components-lib/element-actions-controller.ts new file mode 100644 index 00000000..fe439744 --- /dev/null +++ b/src/components-lib/element-actions-controller.ts @@ -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); + } + } +} diff --git a/src/components/elements.ts b/src/components/elements.ts index 5b484532..3284fa1f 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -12,6 +12,7 @@ import { isEqual } from 'lodash-es'; import type { IssueTriggerEventData } from '../card-controller/issues/types.js'; import type { TemplateRenderer } from '../card-controller/templates/index.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 { getConditionStateManagerViaEvent } from '../condition-trigger/conditions/state-manager-via-event.js'; import type { ConditionStateManager } from '../condition-trigger/conditions/state-manager.js'; @@ -102,6 +103,8 @@ export class AdvancedCameraCardElementsCore extends LitElement { private _renderedElements?: PictureElements; + private _actionsController = new ElementActionsController(); + /** * Create a transparent render root. */ @@ -156,6 +159,7 @@ export class AdvancedCameraCardElementsCore extends LitElement { try { this._renderedElements = elements; this._root = this._createRoot(); + this._actionsController.setRoot(this._root); } catch (e) { errorToConsole(e); fireAdvancedCameraCardEvent(this, 'issue:trigger', { diff --git a/tests/browser/ha-element-stubs.ts b/tests/browser/ha-element-stubs.ts index d1320f61..c7bd656d 100644 --- a/tests/browser/ha-element-stubs.ts +++ b/tests/browser/ha-element-stubs.ts @@ -153,6 +153,22 @@ const isConfigurable = (element: HTMLElement): element is ConfigurableElement => const CUSTOM_ELEMENT_PREFIX = 'custom:'; 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 * 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 * without that a configured menu button never reaches the menu. * - * Only the card's own elements are created here. Other elements (e.g. Home - * Assistant's `icon` or `image`) are skipped, so a test that needs one must add - * it to this stub first. + * Home Assistant's own elements are created as empty stand-ins: they carry what + * Home Assistant puts on them, but render nothing. */ class HuiConditionalElementStub extends HTMLElement { public hass?: unknown; @@ -172,14 +187,13 @@ class HuiConditionalElementStub extends HTMLElement { this.replaceChildren(); for (const element of config.elements ?? []) { - if (!element.type.startsWith(CARD_ELEMENT_PREFIX)) { - continue; - } + const child = element.type.startsWith(CARD_ELEMENT_PREFIX) + ? 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)) { child.setConfig(element); } diff --git a/tests/components-lib/element-actions-controller.test.ts b/tests/components-lib/element-actions-controller.test.ts new file mode 100644 index 00000000..b33a5003 --- /dev/null +++ b/tests/components-lib/element-actions-controller.test.ts @@ -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; + } +} diff --git a/tests/components/elements.browser.test.ts b/tests/components/elements.browser.test.ts new file mode 100644 index 00000000..a1006d06 --- /dev/null +++ b/tests/components/elements.browser.test.ts @@ -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('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); + }); +});