mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
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:
parent
9bad3c8101
commit
04f6a01c3d
@ -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`
|
||||
<g class=${classMap({ inactive: Boolean(this.inactive) })}>
|
||||
<path
|
||||
@ -583,6 +617,18 @@ export class HaControlCircularSlider extends LitElement {
|
||||
${
|
||||
targetCircle
|
||||
? 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
|
||||
class="target-border ${classMap({ [id]: true })}"
|
||||
d=${path}
|
||||
@ -619,6 +665,9 @@ export class HaControlCircularSlider extends LitElement {
|
||||
? this._strokeCircleDashArc(current)
|
||||
: undefined;
|
||||
|
||||
const onlyDotInteraction =
|
||||
(this.preventInteractionOnScroll && isTouch) || false;
|
||||
|
||||
return html`
|
||||
<svg
|
||||
id="slider"
|
||||
@ -634,13 +683,10 @@ export class HaControlCircularSlider extends LitElement {
|
||||
id="container"
|
||||
transform="translate(160 160) rotate(${ROTATE_ANGLE})"
|
||||
>
|
||||
<g id="interaction">
|
||||
<path d=${trackPath} />
|
||||
</g>
|
||||
<g id="display">
|
||||
<path class="background" d=${trackPath} />
|
||||
${currentStroke
|
||||
? svg`
|
||||
<path d=${trackPath} ?data-interaction=${!onlyDotInteraction} />
|
||||
<path class="background" d=${trackPath} />
|
||||
${currentStroke
|
||||
? svg`
|
||||
<path
|
||||
class="current"
|
||||
d=${trackPath}
|
||||
@ -648,18 +694,17 @@ export class HaControlCircularSlider extends LitElement {
|
||||
stroke-dashoffset=${currentStroke[1]}
|
||||
/>
|
||||
`
|
||||
: 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}
|
||||
</g>
|
||||
: 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}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
5
src/util/is_touch.ts
Normal file
5
src/util/is_touch.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const isTouch =
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
// @ts-ignore
|
||||
navigator.msMaxTouchPoints > 0;
|
Loading…
x
Reference in New Issue
Block a user