From f7daf893b39cff3300e6eb72fde7c452c949bb57 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Thu, 1 Dec 2022 15:58:00 +0200 Subject: [PATCH] Add tap actions --- README.md | 33 ++-- devtools/homeassistant/ui-lovelace.yaml | 29 ++++ src/action-handler-directive.ts | 204 ++++++++++++++++++++++++ src/partials.ts | 23 +-- src/time-picker-card.ts | 75 ++++++++- src/types.ts | 5 +- 6 files changed, 327 insertions(+), 42 deletions(-) create mode 100644 src/action-handler-directive.ts diff --git a/README.md b/README.md index e6020e3..ad0f185 100644 --- a/README.md +++ b/README.md @@ -98,17 +98,20 @@ layout: ## Options -| Name | Type | Requirement | Description | Default | -| ----------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ | -| type | string | **Required** | `custom:time-picker-card` | | -| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | | -| name | string | **Optional** | Card name | Entity's `friendly_name` | -| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` | -| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` | -| hour_step | number | **Optional** | Hour change when clicking arrows | `1` | -| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | -| layout | object | **Optional** | Card Layout configuration | `none` | -| hide | object | **Optional** | Hide object | `none` | +| Name | Type | Requirement | Description | Default | +| ----------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ | +| type | string | **Required** | `custom:time-picker-card` | | +| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | | +| name | string | **Optional** | Card name | Entity's `friendly_name` | +| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` | +| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` | +| hour_step | number | **Optional** | Hour change when clicking arrows | `1` | +| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | +| layout | object | **Optional** | Card Layout configuration | `none` | +| hide | object | **Optional** | Hide object | `none` | +| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | +| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | +| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` | ### Layout Object @@ -121,11 +124,11 @@ layout: ### Hide Object -| Name | Type | Requirement | Description | Default | -| ------- | ------- | ------------ | ------------------- | ------- | -| name | boolean | **Optional** | Hides the card name | `false` | +| Name | Type | Requirement | Description | Default | +| ------- | ------- | ------------ | ------------------------------------------------- | ------- | +| name | boolean | **Optional** | Hides the card name | `false` | | icon | boolean | **Optional** | Hides the card icon (only with controls "inside") | `false` | -| seconds | boolean | **Optional** | Hides seconds input | `true` | +| seconds | boolean | **Optional** | Hides seconds input | `true` | ### Theme Variables diff --git a/devtools/homeassistant/ui-lovelace.yaml b/devtools/homeassistant/ui-lovelace.yaml index d575a11..c2841b8 100644 --- a/devtools/homeassistant/ui-lovelace.yaml +++ b/devtools/homeassistant/ui-lovelace.yaml @@ -17,6 +17,24 @@ views: name: Alarm time hour_mode: 24 hour_step: 2 + tap_action: + action: call-service + service: logbook.log + service_data: + name: "Time picker card · Tap Action" + message: "Debug" + double_tap_action: + action: call-service + service: logbook.log + service_data: + name: "Time picker card · Double Tap Action" + message: "Debug" + hold_action: + action: call-service + service: logbook.log + service_data: + name: "Time picker card · Hold action" + message: "Debug" - type: custom:time-picker-card entity: input_datetime.test_date_time hour_mode: 12 @@ -50,6 +68,17 @@ views: hide: name: false + - type: custom:time-picker-card + entity: input_datetime.test_date + link_values: true + hour_mode: 12 + hour_step: 2 + minute_step: 3 + layout: + hour_mode: single + hide: + name: false + - title: embedded path: embedded cards: diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts new file mode 100644 index 0000000..66b6377 --- /dev/null +++ b/src/action-handler-directive.ts @@ -0,0 +1,204 @@ +import { noChange } from 'lit'; +import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js'; + +import { fireEvent } from 'custom-card-helpers'; +import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types'; + +const isTouch = + 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0; + +interface ActionHandler extends HTMLElement { + holdTime: number; + bind(element: Element, options): void; +} +interface ActionHandlerElement extends HTMLElement { + actionHandler?: boolean; +} + +declare global { + interface HASSDomEvents { + action: ActionHandlerDetail; + } +} + +class ActionHandler extends HTMLElement implements ActionHandler { + public holdTime = 500; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public ripple: any; + + protected timer?: number; + + protected held = false; + + private dblClickTimeout?: number; + + constructor() { + super(); + this.ripple = document.createElement('mwc-ripple'); + } + + public connectedCallback(): void { + Object.assign(this.style, { + position: 'absolute', + width: isTouch ? '100px' : '50px', + height: isTouch ? '100px' : '50px', + transform: 'translate(-50%, -50%)', + pointerEvents: 'none', + zIndex: '999', + }); + + this.appendChild(this.ripple); + this.ripple.primary = true; + + ['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach( + (ev) => { + document.addEventListener( + ev, + () => { + clearTimeout(this.timer); + this.stopAnimation(); + this.timer = undefined; + }, + { passive: true } + ); + } + ); + } + + public bind(element: ActionHandlerElement, options): void { + if (element.actionHandler) { + return; + } + element.actionHandler = true; + + element.addEventListener('contextmenu', (ev: Event) => { + const e = ev || window.event; + if (e.preventDefault) { + e.preventDefault(); + } + if (e.stopPropagation) { + e.stopPropagation(); + } + e.cancelBubble = true; + e.returnValue = false; + return false; + }); + + const start = (ev: Event): void => { + this.held = false; + let x; + let y; + if ((ev as TouchEvent).touches) { + x = (ev as TouchEvent).touches[0].pageX; + y = (ev as TouchEvent).touches[0].pageY; + } else { + x = (ev as MouseEvent).pageX; + y = (ev as MouseEvent).pageY; + } + + this.timer = window.setTimeout(() => { + this.startAnimation(x, y); + this.held = true; + }, this.holdTime); + }; + + const end = (ev: Event): void => { + // Prevent mouse event if touch event + ev.preventDefault(); + if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) { + return; + } + clearTimeout(this.timer); + this.stopAnimation(); + this.timer = undefined; + if (this.held) { + fireEvent(element, 'action', { action: 'hold' }); + } else if (options.hasDoubleClick) { + if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) { + this.dblClickTimeout = window.setTimeout(() => { + this.dblClickTimeout = undefined; + fireEvent(element, 'action', { action: 'tap' }); + }, 250); + } else { + clearTimeout(this.dblClickTimeout); + this.dblClickTimeout = undefined; + fireEvent(element, 'action', { action: 'double_tap' }); + } + } else { + fireEvent(element, 'action', { action: 'tap' }); + } + }; + + const handleEnter = (ev: KeyboardEvent): void => { + if (ev.keyCode !== 13) { + return; + } + end(ev); + }; + + element.addEventListener('touchstart', start, { passive: true }); + element.addEventListener('touchend', end); + element.addEventListener('touchcancel', end); + + element.addEventListener('mousedown', start, { passive: true }); + element.addEventListener('click', end); + + element.addEventListener('keyup', handleEnter); + } + + private startAnimation(x: number, y: number): void { + Object.assign(this.style, { + left: `${x}px`, + top: `${y}px`, + display: null, + }); + this.ripple.disabled = false; + this.ripple.active = true; + this.ripple.unbounded = true; + } + + private stopAnimation(): void { + this.ripple.active = false; + this.ripple.disabled = true; + this.style.display = 'none'; + } +} + +// TODO You need to replace all instances of "action-handler-boilerplate" with "action-handler-" +customElements.define('action-handler-boilerplate', ActionHandler); + +const getActionHandler = (): ActionHandler => { + const body = document.body; + if (body.querySelector('action-handler-boilerplate')) { + return body.querySelector('action-handler-boilerplate') as ActionHandler; + } + + const actionhandler = document.createElement('action-handler-boilerplate'); + body.appendChild(actionhandler); + + return actionhandler as ActionHandler; +}; + +export const actionHandlerBind = ( + element: ActionHandlerElement, + options?: ActionHandlerOptions +): void => { + const actionhandler: ActionHandler = getActionHandler(); + if (!actionhandler) { + return; + } + actionhandler.bind(element, options); +}; + +export const actionHandler = directive( + class extends Directive { + update(part: AttributePart, [options]: DirectiveParameters) { + actionHandlerBind(part.element as ActionHandlerElement, options); + return noChange; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars + render(_options?: ActionHandlerOptions) {} + } +); diff --git a/src/partials.ts b/src/partials.ts index 81dce48..821cb72 100644 --- a/src/partials.ts +++ b/src/partials.ts @@ -1,7 +1,6 @@ import { LovelaceCard } from 'custom-card-helpers'; -import { HassEntity } from 'home-assistant-js-websocket'; import { html, TemplateResult } from 'lit'; -import { TimePickerCardConfig, TimePickerHideConfig } from './types'; +import { TimePickerCardConfig } from './types'; export class Partial { static error(error: string, origConfig: TimePickerCardConfig): TemplateResult { @@ -14,24 +13,4 @@ export class Partial { return html`${errorCard}`; } - - static headerName(title: string): TemplateResult { - return html`
${title}
`; - } - - static nestedName(name: string, entity: HassEntity, hide?: TimePickerHideConfig): TemplateResult { - const icon = html``; - const label = html`
${name}
`; - - const visibleElements = [ - { show: !hide?.icon, value: icon }, - { show: !hide?.name, value: label }, - ] - .filter(({ show }) => show) - .map(({ value }) => value); - - return html`${visibleElements}`; - - // return hideIcon ? label : html`${icon}${label}`; - } } diff --git a/src/time-picker-card.ts b/src/time-picker-card.ts index 93445af..af2ef42 100644 --- a/src/time-picker-card.ts +++ b/src/time-picker-card.ts @@ -1,8 +1,16 @@ -import { computeDomain, HomeAssistant, LovelaceCard } from 'custom-card-helpers'; +import { + ActionHandlerEvent, + computeDomain, + handleAction, + hasAction, + HomeAssistant, + LovelaceCard, +} from 'custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; import { css, CSSResult, html, LitElement, TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { actionHandler } from './action-handler-directive'; import './components/time-period.component'; import './components/time-unit.component'; import { @@ -18,7 +26,7 @@ import { Minute } from './models/minute'; import { Second } from './models/second'; import { Time } from './models/time'; import { Partial } from './partials'; -import { Layout, Period, TimePickerCardConfig } from './types'; +import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './types'; console.info( `%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `, @@ -97,6 +105,58 @@ export class TimePickerCard extends LitElement implements LovelaceCard { }; } + private handleAction(ev: ActionHandlerEvent) { + handleAction(this, this.hass, this.config, ev.detail.action!); + } + + private renderHeaderName(title: string): TemplateResult { + return html`
+ ${title} +
`; + } + + private renderNestedName( + name: string, + entity: HassEntity, + hide?: TimePickerHideConfig + ): TemplateResult { + const icon = html``; + const label = html`
+ ${name} +
`; + + const visibleElements = [ + { element: icon, visible: !hide?.icon }, + { element: label, visible: !hide?.name }, + ] + .filter(({ visible }) => visible) + .map(({ element }) => element); + + return html`${visibleElements}`; + } + render(): TemplateResult | null { if (!this.entity) { return Partial.error('Entity not found', this.config); @@ -122,9 +182,11 @@ export class TimePickerCard extends LitElement implements LovelaceCard { return html` - ${this.hasNameInHeader ? Partial.headerName(this.name!) : ''} + ${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
- ${this.hasNameInside ? Partial.nestedName(this.name!, this.entity, this.config.hide) : ''} + ${this.hasNameInside + ? this.renderNestedName(this.name!, this.entity, this.config.hide) + : ''}