Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"author": "benct <ben@tomlin.no>",
"license": "MIT",
"dependencies": {
"custom-card-helpers": "2.0.0",
"lit": "^3.3.3",
"memoize-one": "^6.0.0"
},
Expand Down
50 changes: 33 additions & 17 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { css, html, LitElement } from 'lit';
import { handleClick } from 'custom-card-helpers';

import { LAST_CHANGED, LAST_UPDATED, TIMESTAMP_FORMATS } from './lib/constants';
import { createGestureHandlers } from './lib/gesture_handler';
import { checkEntity, entityName, entityStateDisplay, entityStyles } from './entity';
import { getEntityIds, hasConfigOrEntitiesChanged, hasGenericSecondaryInfo, hideIf, isObject } from './util';
import {
fireEvent,
getEntityIds,
hasConfigOrEntitiesChanged,
hasGenericSecondaryInfo,
hideIf,
isObject,
} from './util';
import { style } from './styles';

// hui-generic-entity-row attaches its own tap/hold/double-tap detection to the outer row
Expand Down Expand Up @@ -234,28 +240,38 @@ class MultipleEntityRow extends LitElement {
if (!this._actionHandlers.has(key)) {
this._actionHandlers.set(
key,
createGestureHandlers(
(hold, dblClick) => this.dispatchAction(entity, config, hold, dblClick),
!!config.double_tap_action
)
createGestureHandlers((hold, dblClick) => this.dispatchAction(entity, config, hold, dblClick), {
hasHold: !!config.hold_action,
hasDoubleTap: !!config.double_tap_action,
})
);
}
return this._actionHandlers.get(key);
}

// Dispatch by firing HA's own hass-action event rather than performing the action ourselves
// (the old custom-card-helpers handleClick call). Letting HA core execute it keeps native
// confirmation dialogs and security-domain restrictions (lock/cover) in the loop, and
// supports newer action types (perform-action, assist) for free. Approach adopted from the
// duczz/ha-multiple-entity-row fork.
dispatchAction(entity, config, hold, dblClick) {
handleClick(
this,
this._hass,
{
entity,
tap_action: config.tap_action,
hold_action: config.hold_action,
double_tap_action: config.double_tap_action,
const actionConfig = dblClick
? config.double_tap_action
: hold
? config.hold_action
: config.tap_action ?? { action: 'more-info' };
if (!actionConfig || actionConfig.action === 'none') {
return;
}
const actionType = dblClick ? 'double_tap' : hold ? 'hold' : 'tap';
fireEvent(this, 'hass-action', {
config: {
// actionConfig.entity overrides the more-info target (see #188)
entity: actionConfig.entity || entity,
[`${actionType}_action`]: actionConfig,
},
hold,
dblClick
);
action: actionType,
});
}
}

Expand Down
48 changes: 33 additions & 15 deletions src/index.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { handleClick } = vi.hoisted(() => ({ handleClick: vi.fn() }));
vi.mock('custom-card-helpers', () => ({ handleClick }));

import './index';

const flushRender = async (el) => {
Expand All @@ -22,7 +19,6 @@ describe('multiple-entity-row', () => {
let el;

beforeEach(() => {
handleClick.mockClear();
el = document.createElement('multiple-entity-row');
document.body.appendChild(el);
});
Expand Down Expand Up @@ -117,8 +113,12 @@ describe('multiple-entity-row', () => {
// anywhere in the row (including on a sub-entity) also bubbled into hui-generic-entity-row's
// own row-level action handling, which only ever knew about the main entity's config.
describe('gesture handling', () => {
let actions;

beforeEach(() => {
vi.useFakeTimers();
actions = [];
el.addEventListener('hass-action', (ev) => actions.push(ev.detail));
el.setConfig({
entity: 'sensor.main',
tap_action: { action: 'toggle' },
Expand Down Expand Up @@ -147,27 +147,45 @@ describe('multiple-entity-row', () => {
const sub = el.getGestureHandlers('sub-0', 'sensor.a', el.config.entities[0]);
sub.onDown();
sub.onUp();
expect(handleClick).toHaveBeenCalledExactlyOnceWith(
el,
el._hass,
expect(actions).toEqual([
{
entity: 'sensor.a',
tap_action: { action: 'more-info', entity: 'sensor.a' },
hold_action: undefined,
double_tap_action: undefined,
config: { entity: 'sensor.a', tap_action: { action: 'more-info', entity: 'sensor.a' } },
action: 'tap',
},
false,
false
);
]);
});

// Handlers are cached per key until the next setConfig, so these tests reset the config
// (clearing the cache) rather than passing an ad-hoc config for an already-cached key.
it('dispatches a hold to hold_action for a sub-entity', () => {
const subConfig = { entity: 'sensor.a', tap_action: { action: 'toggle' }, hold_action: { action: 'more-info' } };
el.setConfig({ entity: 'sensor.main', entities: [subConfig] });
const sub = el.getGestureHandlers('sub-0', 'sensor.a', subConfig);
sub.onDown();
vi.advanceTimersByTime(500);
sub.onUp();
expect(handleClick).toHaveBeenCalledExactlyOnceWith(el, el._hass, expect.objectContaining({ entity: 'sensor.a' }), true, false);
expect(actions).toEqual([
{ config: { entity: 'sensor.a', hold_action: { action: 'more-info' } }, action: 'hold' },
]);
});

it('defaults a tap with no tap_action to more-info', () => {
el.setConfig({ entity: 'sensor.main', entities: [{ entity: 'sensor.a' }] });
const sub = el.getGestureHandlers('sub-0', 'sensor.a', { entity: 'sensor.a' });
sub.onDown();
sub.onUp();
expect(actions).toEqual([
{ config: { entity: 'sensor.a', tap_action: { action: 'more-info' } }, action: 'tap' },
]);
});

it('does not dispatch when the action is none', () => {
const subConfig = { entity: 'sensor.a', tap_action: { action: 'none' } };
el.setConfig({ entity: 'sensor.main', entities: [subConfig] });
const sub = el.getGestureHandlers('sub-0', 'sensor.a', subConfig);
sub.onDown();
sub.onUp();
expect(actions).toEqual([]);
});

it('does not attach gesture handlers for a toggle-mode entity', () => {
Expand Down
25 changes: 14 additions & 11 deletions src/lib/gesture_handler.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
// Detects tap/hold/double-tap gestures from pointer events and dispatches the resolved gesture
// via the given callback. custom-card-helpers only dispatches a *given* gesture (handleClick takes
// hold/dblClick booleans) - it doesn't detect one, and HA's own internal <action-handler> element
// isn't exposed for third-party use - so this reimplements that detection directly.
// via the given callback. HA's own internal <action-handler> element isn't exposed for
// third-party use, so this reimplements that detection directly.
//
// Kept framework-free (no DOM/Lit dependency) so gesture timing can be unit tested with fake
// timers, independent of rendering.

export const HOLD_TIME_MS = 500;
export const DOUBLE_TAP_WINDOW_MS = 250;

// dispatch(hold, dblClick) is called once per resolved gesture. hasDoubleTapAction should be true
// only when a double_tap_action is actually configured - otherwise a plain tap dispatches
// immediately instead of waiting out the double-tap window for a second tap that will never come.
export const createGestureHandlers = (dispatch, hasDoubleTapAction) => {
// dispatch(hold, dblClick) is called once per resolved gesture. hasHold/hasDoubleTap should be
// true only when the corresponding action is actually configured - like HA's native rows, a
// long-press without a hold_action resolves as a plain tap, and without a double_tap_action a
// tap dispatches immediately instead of waiting out the double-tap window for a second tap that
// will never come.
export const createGestureHandlers = (dispatch, { hasHold, hasDoubleTap }) => {
let holdTimer;
let held = false;
let pendingTapTimer;

const onDown = () => {
held = false;
holdTimer = setTimeout(() => {
held = true;
}, HOLD_TIME_MS);
if (hasHold) {
holdTimer = setTimeout(() => {
held = true;
}, HOLD_TIME_MS);
}
};

const onCancel = () => {
Expand All @@ -35,7 +38,7 @@ export const createGestureHandlers = (dispatch, hasDoubleTapAction) => {
dispatch(true, false);
return;
}
if (!hasDoubleTapAction) {
if (!hasDoubleTap) {
dispatch(false, false);
return;
}
Expand Down
24 changes: 17 additions & 7 deletions src/lib/gesture_handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ afterEach(() => {
describe('createGestureHandlers', () => {
it('dispatches a tap immediately when no double-tap action is configured', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, false);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: false });
onDown();
onUp();
expect(dispatch).toHaveBeenCalledExactlyOnceWith(false, false);
});

it('dispatches a tap after the double-tap window elapses when no second tap follows', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, true);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: true });
onDown();
onUp();
expect(dispatch).not.toHaveBeenCalled();
Expand All @@ -29,7 +29,7 @@ describe('createGestureHandlers', () => {

it('dispatches a double-tap when a second tap arrives within the window', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, true);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: true });
onDown();
onUp();
vi.advanceTimersByTime(DOUBLE_TAP_WINDOW_MS / 2);
Expand All @@ -40,16 +40,26 @@ describe('createGestureHandlers', () => {

it('dispatches a hold when the pointer is held past the hold threshold', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, false);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: false });
onDown();
vi.advanceTimersByTime(HOLD_TIME_MS);
onUp();
expect(dispatch).toHaveBeenCalledExactlyOnceWith(true, false);
});

// Matches HA's native rows: without a hold_action, a long-press is just a slow tap.
it('resolves a long-press as a tap when no hold action is configured', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: false, hasDoubleTap: false });
onDown();
vi.advanceTimersByTime(HOLD_TIME_MS * 2);
onUp();
expect(dispatch).toHaveBeenCalledExactlyOnceWith(false, false);
});

it('does not dispatch a hold if released before the hold threshold', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, false);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: false });
onDown();
vi.advanceTimersByTime(HOLD_TIME_MS - 50);
onUp();
Expand All @@ -58,7 +68,7 @@ describe('createGestureHandlers', () => {

it('does not dispatch anything on cancel, and does not leave a stale hold pending', () => {
const dispatch = vi.fn();
const { onDown, onCancel } = createGestureHandlers(dispatch, false);
const { onDown, onCancel } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: false });
onDown();
onCancel();
vi.advanceTimersByTime(HOLD_TIME_MS + DOUBLE_TAP_WINDOW_MS);
Expand All @@ -67,7 +77,7 @@ describe('createGestureHandlers', () => {

it('treats a hold on the second tap of what could have been a double-tap as a hold', () => {
const dispatch = vi.fn();
const { onDown, onUp } = createGestureHandlers(dispatch, true);
const { onDown, onUp } = createGestureHandlers(dispatch, { hasHold: true, hasDoubleTap: true });
onDown();
onUp();
vi.advanceTimersByTime(DOUBLE_TAP_WINDOW_MS / 2);
Expand Down
7 changes: 7 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { LAST_CHANGED, LAST_UPDATED, SECONDARY_INFO_VALUES, UNAVAILABLE_STATES }

export const isObject = (obj) => typeof obj === 'object' && !Array.isArray(obj) && !!obj;

// bubbles + composed so the event crosses our shadow root up to HA's root-level listener.
export const fireEvent = (node, type, detail) => {
const event = new Event(type, { bubbles: true, composed: true });
event.detail = detail;
node.dispatchEvent(event);
};

export const isUnavailable = (stateObj) => !stateObj || UNAVAILABLE_STATES.includes(stateObj.state);

export const hideUnavailable = (stateObj, config) =>
Expand Down
Loading
Loading