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
This commit is contained in:
Paul Bottein 2024-01-18 14:31:44 +01:00 committed by GitHub
parent 9bad3c8101
commit 04f6a01c3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 223 additions and 166 deletions

View File

@ -16,12 +16,19 @@ import {
nothing, nothing,
svg, svg,
} from "lit"; } 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 { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { clamp } from "../common/number/clamp"; import { clamp } from "../common/number/clamp";
import { svgArc } from "../resources/svg-arc"; import { svgArc } from "../resources/svg-arc";
import { isTouch } from "../util/is_touch";
const MAX_ANGLE = 270; const MAX_ANGLE = 270;
const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90; 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); return Math.min(Math.max(value, min), max);
} }
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._setupListeners();
}
protected updated(changedProps: PropertyValues): void { protected updated(changedProps: PropertyValues): void {
super.updated(changedProps); super.updated(changedProps);
if (!this._activeSlider) { if (!this._activeSlider) {
@ -171,6 +173,19 @@ export class HaControlCircularSlider extends LitElement {
this._localHigh = this.high; 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 { connectedCallback(): void {
@ -182,7 +197,7 @@ export class HaControlCircularSlider extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
private _mc?: HammerManager; private _managers: HammerManager[] = [];
private _getPercentageFromEvent = (e: HammerInput) => { private _getPercentageFromEvent = (e: HammerInput) => {
const bound = this._slider.getBoundingClientRect(); const bound = this._slider.getBoundingClientRect();
@ -201,8 +216,8 @@ export class HaControlCircularSlider extends LitElement {
@query("#slider") @query("#slider")
private _slider; private _slider;
@query("#interaction") @queryAll("[data-interaction]")
private _interaction; private _interactions?: HTMLElement[];
private _findActiveSlider(value: number): ActiveSlider { private _findActiveSlider(value: number): ActiveSlider {
if (!this.dual) return "value"; if (!this.dual) return "value";
@ -245,135 +260,148 @@ export class HaControlCircularSlider extends LitElement {
return undefined; return undefined;
} }
_setupListeners() { private _setupListeners() {
if (this._interaction && !this._mc) { if (this._interactions && this._managers.length === 0) {
this._mc = new Manager(this._interaction, { this._interactions.forEach((interaction) => {
inputClass: TouchMouseInput, const mc = new Manager(interaction, {
}); inputClass: TouchMouseInput,
});
const pressToActivate = this._managers.push(mc);
this.preventInteractionOnScroll && "ontouchstart" in window;
// If press to activate is true, a 60ms press is required to activate the slider const pressToActivate = this.preventInteractionOnScroll && isTouch;
this._mc.add(
new Press({
enable: pressToActivate,
pointers: 1,
time: 60,
})
);
const panRecognizer = new Pan({ // If press to activate is true, a 50ms press is required to activate the slider
direction: DIRECTION_ALL, mc.add(
enable: !pressToActivate, new Press({
threshold: 0, 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) => { mc.add(new Tap({ event: "singletap" }));
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("pressup", (e) => { mc.on("press", (e) => {
e.srcEvent.stopPropagation(); e.srcEvent.stopPropagation();
e.srcEvent.preventDefault(); e.srcEvent.preventDefault();
const percentage = this._getPercentageFromEvent(e); if (this.disabled || this.readonly) return;
const raw = this._percentageToValue(percentage); const percentage = this._getPercentageFromEvent(e);
const bounded = this._boundedValue(raw); const raw = this._percentageToValue(percentage);
const stepped = this._steppedValue(bounded); this._activeSlider = this._findActiveSlider(raw);
this._setActiveValue(stepped); const bounded = this._boundedValue(raw);
if (this._activeSlider) { this._setActiveValue(bounded);
fireEvent(this, `${this._activeSlider}-changing`, { const stepped = this._steppedValue(bounded);
value: undefined, if (this._activeSlider) {
}); fireEvent(this, `${this._activeSlider}-changing`, {
fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); value: stepped,
} });
this._activeSlider = undefined; }
}); panRecognizer.set({ enable: true });
});
this._mc.on("pan", (e) => { mc.on("pressup", (e) => {
e.srcEvent.stopPropagation(); e.srcEvent.stopPropagation();
e.srcEvent.preventDefault(); e.srcEvent.preventDefault();
}); const percentage = this._getPercentageFromEvent(e);
this._mc.on("panstart", (e) => { const raw = this._percentageToValue(percentage);
if (this.disabled || this.readonly) return; const bounded = this._boundedValue(raw);
const percentage = this._getPercentageFromEvent(e); const stepped = this._steppedValue(bounded);
const raw = this._percentageToValue(percentage); this._setActiveValue(stepped);
this._activeSlider = this._findActiveSlider(raw); if (this._activeSlider) {
this._lastSlider = this._activeSlider; fireEvent(this, `${this._activeSlider}-changing`, {
this.shadowRoot?.getElementById("#slider")?.focus(); value: undefined,
}); });
this._mc.on("pancancel", () => { fireEvent(this, `${this._activeSlider}-changed`, {
if (this.disabled || this.readonly) return; value: stepped,
this._activeSlider = undefined; });
if (pressToActivate) { }
panRecognizer.set({ enable: false }); this._activeSlider = undefined;
} });
});
this._mc.on("panmove", (e) => { mc.on("pan", (e) => {
if (this.disabled || this.readonly) return; e.srcEvent.stopPropagation();
const percentage = this._getPercentageFromEvent(e); e.srcEvent.preventDefault();
const raw = this._percentageToValue(percentage); });
const bounded = this._boundedValue(raw); mc.on("panstart", (e) => {
this._setActiveValue(bounded); if (this.disabled || this.readonly) return;
const stepped = this._steppedValue(bounded); const percentage = this._getPercentageFromEvent(e);
if (this._activeSlider) { const raw = this._percentageToValue(percentage);
fireEvent(this, `${this._activeSlider}-changing`, { value: stepped }); this._activeSlider = this._findActiveSlider(raw);
} this._lastSlider = this._activeSlider;
}); this.shadowRoot?.getElementById("#slider")?.focus();
this._mc.on("panend", (e) => { });
if (this.disabled || this.readonly) return; mc.on("pancancel", () => {
const percentage = this._getPercentageFromEvent(e); if (this.disabled || this.readonly) return;
const raw = this._percentageToValue(percentage); this._activeSlider = undefined;
const bounded = this._boundedValue(raw); if (pressToActivate) {
const stepped = this._steppedValue(bounded); panRecognizer.set({ enable: false });
this._setActiveValue(stepped); }
if (this._activeSlider) { });
fireEvent(this, `${this._activeSlider}-changing`, { mc.on("panmove", (e) => {
value: undefined, if (this.disabled || this.readonly) return;
}); const percentage = this._getPercentageFromEvent(e);
fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); const raw = this._percentageToValue(percentage);
} const bounded = this._boundedValue(raw);
this._activeSlider = undefined; this._setActiveValue(bounded);
if (pressToActivate) { const stepped = this._steppedValue(bounded);
panRecognizer.set({ enable: false }); if (this._activeSlider) {
} fireEvent(this, `${this._activeSlider}-changing`, {
}); value: stepped,
this._mc.on("singletap", (e) => { });
if (this.disabled || this.readonly) return; }
const percentage = this._getPercentageFromEvent(e); });
const raw = this._percentageToValue(percentage); mc.on("panend", (e) => {
this._activeSlider = this._findActiveSlider(raw); if (this.disabled || this.readonly) return;
const bounded = this._boundedValue(raw); const percentage = this._getPercentageFromEvent(e);
const stepped = this._steppedValue(bounded); const raw = this._percentageToValue(percentage);
this._setActiveValue(stepped); const bounded = this._boundedValue(raw);
if (this._activeSlider) { const stepped = this._steppedValue(bounded);
fireEvent(this, `${this._activeSlider}-changing`, { this._setActiveValue(stepped);
value: undefined, if (this._activeSlider) {
}); fireEvent(this, `${this._activeSlider}-changing`, {
fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); value: undefined,
} });
this._lastSlider = this._activeSlider; fireEvent(this, `${this._activeSlider}-changed`, {
this.shadowRoot?.getElementById("#slider")?.focus(); value: stepped,
this._activeSlider = undefined; });
if (pressToActivate) { }
panRecognizer.set({ enable: false }); 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; this._activeSlider = undefined;
} }
destroyListeners() { private _destroyListeners() {
if (this._mc) { if (this._managers.length > 0) {
this._mc.destroy(); this._managers.forEach((manager) => manager.destroy());
this._mc = undefined; this._managers = [];
} }
} }
@ -486,6 +514,9 @@ export class HaControlCircularSlider extends LitElement {
r: RADIUS, r: RADIUS,
}); });
const angle =
value != null ? this._valueToPercentage(value) * MAX_ANGLE : undefined;
const limit = mode === "end" ? this.max : this.min; const limit = mode === "end" ? this.max : this.min;
const current = this.current ?? limit; const current = this.current ?? limit;
@ -527,6 +558,9 @@ export class HaControlCircularSlider extends LitElement {
? this._strokeCircleDashArc(this.current) ? this._strokeCircleDashArc(this.current)
: undefined; : undefined;
const onlyDotInteraction =
(this.preventInteractionOnScroll && isTouch) || false;
return svg` return svg`
<g class=${classMap({ inactive: Boolean(this.inactive) })}> <g class=${classMap({ inactive: Boolean(this.inactive) })}>
<path <path
@ -583,6 +617,18 @@ export class HaControlCircularSlider extends LitElement {
${ ${
targetCircle targetCircle
? svg` ? svg`
<!-- Use circle instead of path for interaction (Safari doesn't support well pointer-events with stroke-dasharray) -->
<circle
transform="rotate(${angle} 0 0)"
?data-interaction=${onlyDotInteraction}
cx=${RADIUS}
cy="0"
/>
<path
d=${path}
stroke-dasharray=${targetCircle[0]}
stroke-dashoffset=${targetCircle[1]}
/>
<path <path
class="target-border ${classMap({ [id]: true })}" class="target-border ${classMap({ [id]: true })}"
d=${path} d=${path}
@ -619,6 +665,9 @@ export class HaControlCircularSlider extends LitElement {
? this._strokeCircleDashArc(current) ? this._strokeCircleDashArc(current)
: undefined; : undefined;
const onlyDotInteraction =
(this.preventInteractionOnScroll && isTouch) || false;
return html` return html`
<svg <svg
id="slider" id="slider"
@ -634,13 +683,10 @@ export class HaControlCircularSlider extends LitElement {
id="container" id="container"
transform="translate(160 160) rotate(${ROTATE_ANGLE})" transform="translate(160 160) rotate(${ROTATE_ANGLE})"
> >
<g id="interaction"> <path d=${trackPath} ?data-interaction=${!onlyDotInteraction} />
<path d=${trackPath} /> <path class="background" d=${trackPath} />
</g> ${currentStroke
<g id="display"> ? svg`
<path class="background" d=${trackPath} />
${currentStroke
? svg`
<path <path
class="current" class="current"
d=${trackPath} d=${trackPath}
@ -648,18 +694,17 @@ export class HaControlCircularSlider extends LitElement {
stroke-dashoffset=${currentStroke[1]} stroke-dashoffset=${currentStroke[1]}
/> />
` `
: nothing} : nothing}
${lowValue != null || this.mode === "full" ${lowValue != null || this.mode === "full"
? this.renderArc( ? this.renderArc(
this.dual ? "low" : "value", this.dual ? "low" : "value",
lowValue, lowValue,
(!this.dual && this.mode) || "start" (!this.dual && this.mode) || "start"
) )
: nothing} : nothing}
${this.dual && highValue != null ${this.dual && highValue != null
? this.renderArc("high", highValue, "end") ? this.renderArc("high", highValue, "end")
: nothing} : nothing}
</g>
</g> </g>
</svg> </svg>
`; `;
@ -684,26 +729,34 @@ export class HaControlCircularSlider extends LitElement {
svg { svg {
width: 100%; width: 100%;
display: block; display: block;
pointer-events: none;
}
g {
fill: none;
} }
#slider { #slider {
outline: none; outline: none;
} }
#interaction { path[data-interaction] {
display: flex;
fill: none; fill: none;
cursor: pointer;
pointer-events: auto;
stroke: transparent; stroke: transparent;
stroke-linecap: round; stroke-linecap: round;
stroke-width: calc( stroke-width: calc(
24px + 2 * var(--control-circular-slider-interaction-margin) 24px + 2 * var(--control-circular-slider-interaction-margin)
); );
}
circle[data-interaction] {
r: calc(12px + var(--control-circular-slider-interaction-margin));
fill: transparent;
cursor: pointer; cursor: pointer;
pointer-events: auto;
} }
#display { :host([disabled]) [data-interaction],
pointer-events: none; :host([readonly]) [data-interaction] {
}
:host([disabled]) #interaction,
:host([readonly]) #interaction {
cursor: initial; cursor: initial;
pointer-events: none;
} }
.background { .background {

View File

@ -14,12 +14,7 @@ import {
ActionHandlerDetail, ActionHandlerDetail,
ActionHandlerOptions, ActionHandlerOptions,
} from "../../../../data/lovelace/action_handler"; } from "../../../../data/lovelace/action_handler";
import { isTouch } from "../../../../util/is_touch";
const isTouch =
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore
navigator.msMaxTouchPoints > 0;
interface ActionHandlerType extends HTMLElement { interface ActionHandlerType extends HTMLElement {
holdTime: number; holdTime: number;

View File

@ -65,6 +65,10 @@ export const stateControlCircularSliderStyle = css`
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none;
}
.buttons > * {
pointer-events: auto;
} }
.primary-state { .primary-state {
font-size: 36px; font-size: 36px;

5
src/util/is_touch.ts Normal file
View File

@ -0,0 +1,5 @@
export const isTouch =
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore
navigator.msMaxTouchPoints > 0;