From 04f6a01c3d27ce300426051d27f186cf3b993e08 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 18 Jan 2024 14:31:44 +0100 Subject: [PATCH] Avoid thermostat card interaction on scroll on touch devices (#19423) * Avoid thermostat card interaction on scroll on touch devices * Fix background interaction on safari * Fix interaction in Safari * Make listeners private --- src/components/ha-control-circular-slider.ts | 373 ++++++++++-------- .../directives/action-handler-directive.ts | 7 +- .../state-control-circular-slider-style.ts | 4 + src/util/is_touch.ts | 5 + 4 files changed, 223 insertions(+), 166 deletions(-) create mode 100644 src/util/is_touch.ts diff --git a/src/components/ha-control-circular-slider.ts b/src/components/ha-control-circular-slider.ts index 2bdb3dc401..eb475aacb5 100644 --- a/src/components/ha-control-circular-slider.ts +++ b/src/components/ha-control-circular-slider.ts @@ -16,12 +16,19 @@ import { nothing, svg, } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { + customElement, + property, + query, + queryAll, + state, +} from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; import { clamp } from "../common/number/clamp"; import { svgArc } from "../resources/svg-arc"; +import { isTouch } from "../util/is_touch"; const MAX_ANGLE = 270; const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90; @@ -153,11 +160,6 @@ export class HaControlCircularSlider extends LitElement { return Math.min(Math.max(value, min), max); } - protected firstUpdated(changedProps: PropertyValues): void { - super.firstUpdated(changedProps); - this._setupListeners(); - } - protected updated(changedProps: PropertyValues): void { super.updated(changedProps); if (!this._activeSlider) { @@ -171,6 +173,19 @@ export class HaControlCircularSlider extends LitElement { this._localHigh = this.high; } } + + if ( + (changedProps.has("_localValue") && + changedProps.get("_localValue") == null) || + (changedProps.has("_localLow") && + changedProps.get("_localLow") == null) || + (changedProps.has("_localHigh") && + changedProps.get("_localHigh") == null) || + changedProps.has("preventInteractionOnScroll") + ) { + this._destroyListeners(); + this._setupListeners(); + } } connectedCallback(): void { @@ -182,7 +197,7 @@ export class HaControlCircularSlider extends LitElement { super.disconnectedCallback(); } - private _mc?: HammerManager; + private _managers: HammerManager[] = []; private _getPercentageFromEvent = (e: HammerInput) => { const bound = this._slider.getBoundingClientRect(); @@ -201,8 +216,8 @@ export class HaControlCircularSlider extends LitElement { @query("#slider") private _slider; - @query("#interaction") - private _interaction; + @queryAll("[data-interaction]") + private _interactions?: HTMLElement[]; private _findActiveSlider(value: number): ActiveSlider { if (!this.dual) return "value"; @@ -245,135 +260,148 @@ export class HaControlCircularSlider extends LitElement { return undefined; } - _setupListeners() { - if (this._interaction && !this._mc) { - this._mc = new Manager(this._interaction, { - inputClass: TouchMouseInput, - }); + private _setupListeners() { + if (this._interactions && this._managers.length === 0) { + this._interactions.forEach((interaction) => { + const mc = new Manager(interaction, { + inputClass: TouchMouseInput, + }); - const pressToActivate = - this.preventInteractionOnScroll && "ontouchstart" in window; + this._managers.push(mc); - // If press to activate is true, a 60ms press is required to activate the slider - this._mc.add( - new Press({ - enable: pressToActivate, - pointers: 1, - time: 60, - }) - ); + const pressToActivate = this.preventInteractionOnScroll && isTouch; - const panRecognizer = new Pan({ - direction: DIRECTION_ALL, - enable: !pressToActivate, - threshold: 0, - }); + // If press to activate is true, a 50ms press is required to activate the slider + mc.add( + new Press({ + enable: pressToActivate, + pointers: 1, + time: 50, + }) + ); - this._mc.add(panRecognizer); + const panRecognizer = new Pan({ + direction: DIRECTION_ALL, + enable: !pressToActivate, + threshold: 0, + }); - this._mc.add(new Tap({ event: "singletap" })); + mc.add(panRecognizer); - this._mc.on("press", (e) => { - e.srcEvent.stopPropagation(); - e.srcEvent.preventDefault(); - if (this.disabled || this.readonly) return; - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - this._activeSlider = this._findActiveSlider(raw); - const bounded = this._boundedValue(raw); - this._setActiveValue(bounded); - const stepped = this._steppedValue(bounded); - if (this._activeSlider) { - fireEvent(this, `${this._activeSlider}-changing`, { value: stepped }); - } - panRecognizer.set({ enable: true }); - }); + mc.add(new Tap({ event: "singletap" })); - this._mc.on("pressup", (e) => { - e.srcEvent.stopPropagation(); - e.srcEvent.preventDefault(); - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - const bounded = this._boundedValue(raw); - const stepped = this._steppedValue(bounded); - this._setActiveValue(stepped); - if (this._activeSlider) { - fireEvent(this, `${this._activeSlider}-changing`, { - value: undefined, - }); - fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); - } - this._activeSlider = undefined; - }); + mc.on("press", (e) => { + e.srcEvent.stopPropagation(); + e.srcEvent.preventDefault(); + if (this.disabled || this.readonly) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + this._activeSlider = this._findActiveSlider(raw); + const bounded = this._boundedValue(raw); + this._setActiveValue(bounded); + const stepped = this._steppedValue(bounded); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: stepped, + }); + } + panRecognizer.set({ enable: true }); + }); - this._mc.on("pan", (e) => { - e.srcEvent.stopPropagation(); - e.srcEvent.preventDefault(); - }); - this._mc.on("panstart", (e) => { - if (this.disabled || this.readonly) return; - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - this._activeSlider = this._findActiveSlider(raw); - this._lastSlider = this._activeSlider; - this.shadowRoot?.getElementById("#slider")?.focus(); - }); - this._mc.on("pancancel", () => { - if (this.disabled || this.readonly) return; - this._activeSlider = undefined; - if (pressToActivate) { - panRecognizer.set({ enable: false }); - } - }); - this._mc.on("panmove", (e) => { - if (this.disabled || this.readonly) return; - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - const bounded = this._boundedValue(raw); - this._setActiveValue(bounded); - const stepped = this._steppedValue(bounded); - if (this._activeSlider) { - fireEvent(this, `${this._activeSlider}-changing`, { value: stepped }); - } - }); - this._mc.on("panend", (e) => { - if (this.disabled || this.readonly) return; - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - const bounded = this._boundedValue(raw); - const stepped = this._steppedValue(bounded); - this._setActiveValue(stepped); - if (this._activeSlider) { - fireEvent(this, `${this._activeSlider}-changing`, { - value: undefined, - }); - fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); - } - this._activeSlider = undefined; - if (pressToActivate) { - panRecognizer.set({ enable: false }); - } - }); - this._mc.on("singletap", (e) => { - if (this.disabled || this.readonly) return; - const percentage = this._getPercentageFromEvent(e); - const raw = this._percentageToValue(percentage); - this._activeSlider = this._findActiveSlider(raw); - const bounded = this._boundedValue(raw); - const stepped = this._steppedValue(bounded); - this._setActiveValue(stepped); - if (this._activeSlider) { - fireEvent(this, `${this._activeSlider}-changing`, { - value: undefined, - }); - fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); - } - this._lastSlider = this._activeSlider; - this.shadowRoot?.getElementById("#slider")?.focus(); - this._activeSlider = undefined; - if (pressToActivate) { - panRecognizer.set({ enable: false }); - } + mc.on("pressup", (e) => { + e.srcEvent.stopPropagation(); + e.srcEvent.preventDefault(); + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + this._setActiveValue(stepped); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { + value: stepped, + }); + } + this._activeSlider = undefined; + }); + + mc.on("pan", (e) => { + e.srcEvent.stopPropagation(); + e.srcEvent.preventDefault(); + }); + mc.on("panstart", (e) => { + if (this.disabled || this.readonly) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + this._activeSlider = this._findActiveSlider(raw); + this._lastSlider = this._activeSlider; + this.shadowRoot?.getElementById("#slider")?.focus(); + }); + mc.on("pancancel", () => { + if (this.disabled || this.readonly) return; + this._activeSlider = undefined; + if (pressToActivate) { + panRecognizer.set({ enable: false }); + } + }); + mc.on("panmove", (e) => { + if (this.disabled || this.readonly) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + this._setActiveValue(bounded); + const stepped = this._steppedValue(bounded); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: stepped, + }); + } + }); + mc.on("panend", (e) => { + if (this.disabled || this.readonly) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + this._setActiveValue(stepped); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { + value: stepped, + }); + } + this._activeSlider = undefined; + if (pressToActivate) { + panRecognizer.set({ enable: false }); + } + }); + mc.on("singletap", (e) => { + if (this.disabled || this.readonly) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + this._activeSlider = this._findActiveSlider(raw); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + this._setActiveValue(stepped); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { + value: stepped, + }); + } + this._lastSlider = this._activeSlider; + this.shadowRoot?.getElementById("#slider")?.focus(); + this._activeSlider = undefined; + if (pressToActivate) { + panRecognizer.set({ enable: false }); + } + }); }); } } @@ -447,10 +475,10 @@ export class HaControlCircularSlider extends LitElement { this._activeSlider = undefined; } - destroyListeners() { - if (this._mc) { - this._mc.destroy(); - this._mc = undefined; + private _destroyListeners() { + if (this._managers.length > 0) { + this._managers.forEach((manager) => manager.destroy()); + this._managers = []; } } @@ -486,6 +514,9 @@ export class HaControlCircularSlider extends LitElement { r: RADIUS, }); + const angle = + value != null ? this._valueToPercentage(value) * MAX_ANGLE : undefined; + const limit = mode === "end" ? this.max : this.min; const current = this.current ?? limit; @@ -527,6 +558,9 @@ export class HaControlCircularSlider extends LitElement { ? this._strokeCircleDashArc(this.current) : undefined; + const onlyDotInteraction = + (this.preventInteractionOnScroll && isTouch) || false; + return svg` + + - - - - - - ${currentStroke - ? svg` + + + ${currentStroke + ? svg` ` - : nothing} - ${lowValue != null || this.mode === "full" - ? this.renderArc( - this.dual ? "low" : "value", - lowValue, - (!this.dual && this.mode) || "start" - ) - : nothing} - ${this.dual && highValue != null - ? this.renderArc("high", highValue, "end") - : nothing} - + : nothing} + ${lowValue != null || this.mode === "full" + ? this.renderArc( + this.dual ? "low" : "value", + lowValue, + (!this.dual && this.mode) || "start" + ) + : nothing} + ${this.dual && highValue != null + ? this.renderArc("high", highValue, "end") + : nothing} `; @@ -684,26 +729,34 @@ export class HaControlCircularSlider extends LitElement { svg { width: 100%; display: block; + pointer-events: none; + } + g { + fill: none; } #slider { outline: none; } - #interaction { - display: flex; + path[data-interaction] { fill: none; + cursor: pointer; + pointer-events: auto; stroke: transparent; stroke-linecap: round; stroke-width: calc( 24px + 2 * var(--control-circular-slider-interaction-margin) ); + } + circle[data-interaction] { + r: calc(12px + var(--control-circular-slider-interaction-margin)); + fill: transparent; cursor: pointer; + pointer-events: auto; } - #display { - pointer-events: none; - } - :host([disabled]) #interaction, - :host([readonly]) #interaction { + :host([disabled]) [data-interaction], + :host([readonly]) [data-interaction] { cursor: initial; + pointer-events: none; } .background { diff --git a/src/panels/lovelace/common/directives/action-handler-directive.ts b/src/panels/lovelace/common/directives/action-handler-directive.ts index 199cfafc9b..1336727046 100644 --- a/src/panels/lovelace/common/directives/action-handler-directive.ts +++ b/src/panels/lovelace/common/directives/action-handler-directive.ts @@ -14,12 +14,7 @@ import { ActionHandlerDetail, ActionHandlerOptions, } from "../../../../data/lovelace/action_handler"; - -const isTouch = - "ontouchstart" in window || - navigator.maxTouchPoints > 0 || - // @ts-ignore - navigator.msMaxTouchPoints > 0; +import { isTouch } from "../../../../util/is_touch"; interface ActionHandlerType extends HTMLElement { holdTime: number; diff --git a/src/state-control/state-control-circular-slider-style.ts b/src/state-control/state-control-circular-slider-style.ts index b5d4d5281a..14b9b6e9ff 100644 --- a/src/state-control/state-control-circular-slider-style.ts +++ b/src/state-control/state-control-circular-slider-style.ts @@ -65,6 +65,10 @@ export const stateControlCircularSliderStyle = css` flex-direction: row; align-items: center; justify-content: center; + pointer-events: none; + } + .buttons > * { + pointer-events: auto; } .primary-state { font-size: 36px; diff --git a/src/util/is_touch.ts b/src/util/is_touch.ts new file mode 100644 index 0000000000..d5a0ad1235 --- /dev/null +++ b/src/util/is_touch.ts @@ -0,0 +1,5 @@ +export const isTouch = + "ontouchstart" in window || + navigator.maxTouchPoints > 0 || + // @ts-ignore + navigator.msMaxTouchPoints > 0;