Add circular slider (#16981)

* WIP Create round slider

* Fix interaction on iOS

* Add dual and simple gauge

* Add events

* Rename events

* Use low and high

* Improve dual slider selection

* Add min and max

* Rename component

* Prevents setting a high value lower than low and vice versa

* Add keyboard support

* Fix typings

* Use html

* Update current indicator

* Improve doc

* Fix keyboard focus after mouse interaction

* Don't fallback to value
This commit is contained in:
Paul Bottein 2023-06-21 17:01:45 +02:00 committed by GitHub
parent bbdcc021d4
commit e9961b93f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 770 additions and 1 deletions

View File

@ -0,0 +1,3 @@
---
title: Control Circular Slider
---

View File

@ -0,0 +1,153 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-circular-slider";
import "../../../../src/components/ha-slider";
@customElement("demo-components-ha-control-circular-slider")
export class DemoHaCircularSlider extends LitElement {
@state()
private current = 22;
@state()
private value = 19;
@state()
private high = 25;
@state()
private changingValue?: number;
@state()
private changingHigh?: number;
private _valueChanged(ev) {
this.value = ev.detail.value;
}
private _valueChanging(ev) {
this.changingValue = ev.detail.value;
}
private _highChanged(ev) {
this.high = ev.detail.value;
}
private _highChanging(ev) {
this.changingHigh = ev.detail.value;
}
private _currentChanged(ev) {
this.current = ev.currentTarget.value;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p class="title"><b>Config</b></p>
<div class="field">
<p>Current</p>
<ha-slider
min="10"
max="30"
.value=${this.current}
@change=${this._currentChanged}
pin
></ha-slider>
<p>${this.current} °C</p>
</div>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Single</b></p>
<ha-control-circular-slider
@value-changed=${this._valueChanged}
@value-changing=${this._valueChanging}
.value=${this.value}
.current=${this.current}
step="1"
min="10"
max="30"
></ha-control-circular-slider>
<div>
Value: ${this.value} °C
<br />
Changing:
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
</div>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Dual</b></p>
<ha-control-circular-slider
dual
@low-changed=${this._valueChanged}
@low-changing=${this._valueChanging}
@high-changed=${this._highChanged}
@high-changing=${this._highChanging}
.low=${this.value}
.high=${this.high}
.current=${this.current}
step="1"
min="10"
max="30"
></ha-control-circular-slider>
<div>
Low value: ${this.value} °C
<br />
Low changing:
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
<br />
High value: ${this.high} °C
<br />
High changing:
${this.changingHigh != null ? `${this.changingHigh} °C` : "-"}
</div>
</div>
</ha-card>
`;
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
pre {
margin-top: 0;
margin-bottom: 8px;
}
p {
margin: 0;
}
p.title {
margin-bottom: 12px;
}
ha-control-circular-slider {
--control-circular-slider-color: #ff9800;
--control-circular-slider-background: #ff9800;
--control-circular-slider-background-opacity: 0.3;
}
ha-control-circular-slider[dual] {
--control-circular-slider-high-color: #2196f3;
--control-circular-slider-low-color: #ff9800;
--control-circular-slider-background: var(--disabled-color);
}
.field {
display: flex;
flex-direction: row;
align-items: center;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-control-circular-slider": DemoHaCircularSlider;
}
}

View File

@ -0,0 +1,546 @@
import {
DIRECTION_ALL,
Manager,
Pan,
Tap,
TouchMouseInput,
} from "@egjs/hammerjs";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
svg,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { clamp } from "../common/number/clamp";
import { arc } from "../resources/svg-arc";
const MAX_ANGLE = 270;
const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90;
const RADIUS = 145;
function xy2polar(x: number, y: number) {
const r = Math.sqrt(x * x + y * y);
const phi = Math.atan2(y, x);
return [r, phi];
}
function rad2deg(rad: number) {
return (rad / (2 * Math.PI)) * 360;
}
type ActiveSlider = "low" | "high" | "value";
declare global {
interface HASSDomEvents {
"value-changing": { value: unknown };
"low-changing": { value: unknown };
"low-changed": { value: unknown };
"high-changing": { value: unknown };
"high-changed": { value: unknown };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
@customElement("ha-control-circular-slider")
export class HaControlCircularSlider extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Boolean })
public dual?: boolean;
@property({ type: String })
public label?: string;
@property({ type: String, attribute: "low-label" })
public lowLabel?: string;
@property({ type: String, attribute: "high-label" })
public highLabel?: string;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public current?: number;
@property({ type: Number })
public low?: number;
@property({ type: Number })
public high?: number;
@property({ type: Number })
public step = 1;
@property({ type: Number })
public min = 0;
@property({ type: Number })
public max = 100;
@state()
public _activeSlider?: ActiveSlider;
@state()
public _lastSlider?: ActiveSlider;
private _valueToPercentage(value: number) {
return (
(clamp(value, this.min, this.max) - this.min) / (this.max - this.min)
);
}
private _percentageToValue(value: number) {
return (this.max - this.min) * value + this.min;
}
private _steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
}
private _boundedValue(value: number) {
const min =
this._activeSlider === "high" ? Math.min(this.low ?? this.max) : this.min;
const max =
this._activeSlider === "low" ? Math.max(this.high ?? this.min) : this.max;
return Math.min(Math.max(value, min), max);
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this._setupListeners();
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
}
private _mc?: HammerManager;
private _getPercentageFromEvent = (e: HammerInput) => {
const bound = this._slider.getBoundingClientRect();
const x = (2 * (e.center.x - bound.left - bound.width / 2)) / bound.width;
const y = (2 * (e.center.y - bound.top - bound.height / 2)) / bound.height;
const [, phi] = xy2polar(x, y);
const offset = (360 - MAX_ANGLE) / 2;
const angle = ((rad2deg(phi) + offset - ROTATE_ANGLE + 360) % 360) - offset;
return Math.max(Math.min(angle / MAX_ANGLE, 1), 0);
};
@query("#slider")
private _slider;
@query("#interaction")
private _interaction;
private _findActiveSlider(value: number): ActiveSlider {
if (!this.dual) return "value";
const low = Math.max(this.low ?? this.min, this.min);
const high = Math.min(this.high ?? this.max, this.max);
if (low >= value) {
return "low";
}
if (high <= value) {
return "high";
}
const lowDistance = Math.abs(value - low);
const highDistance = Math.abs(value - high);
return lowDistance <= highDistance ? "low" : "high";
}
private _setActiveValue(value: number) {
if (!this._activeSlider) return;
this[this._activeSlider] = value;
}
private _getActiveValue(): number | undefined {
if (!this._activeSlider) return undefined;
return this[this._activeSlider];
}
_setupListeners() {
if (this._interaction && !this._mc) {
this._mc = new Manager(this._interaction, {
inputClass: TouchMouseInput,
});
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
threshold: 0,
})
);
this._mc.add(new Tap({ event: "singletap" }));
this._mc.on("pan", (e) => {
e.srcEvent.stopPropagation();
e.srcEvent.preventDefault();
});
this._mc.on("panstart", (e) => {
if (this.disabled) 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) return;
this._activeSlider = undefined;
});
this._mc.on("panmove", (e) => {
if (this.disabled) 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) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
const bounded = this._boundedValue(raw);
const stepped = this._steppedValue(bounded);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, { value: stepped });
}
this._activeSlider = undefined;
});
this._mc.on("singletap", (e) => {
if (this.disabled) 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;
});
}
}
private get _tenPercentStep() {
return Math.max(this.step, (this.max - this.min) / 10);
}
private _handleKeyDown(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
if (this._lastSlider) {
this.shadowRoot?.getElementById(this._lastSlider)?.focus();
}
this._activeSlider =
this._lastSlider ?? ((e.currentTarget as any).id as ActiveSlider);
this._lastSlider = undefined;
const value = this._getActiveValue();
switch (e.code) {
case "ArrowRight":
case "ArrowUp":
this._setActiveValue(
this._boundedValue((value ?? this.min) + this.step)
);
break;
case "ArrowLeft":
case "ArrowDown":
this._setActiveValue(
this._boundedValue((value ?? this.min) - this.step)
);
break;
case "PageUp":
this._setActiveValue(
this._steppedValue(
this._boundedValue((value ?? this.min) + this._tenPercentStep)
)
);
break;
case "PageDown":
this._setActiveValue(
this._steppedValue(
this._boundedValue((value ?? this.min) - this._tenPercentStep)
)
);
break;
case "Home":
this._setActiveValue(this._boundedValue(this.min));
break;
case "End":
this._setActiveValue(this._boundedValue(this.max));
break;
}
fireEvent(this, `${this._activeSlider}-changing`, {
value: this._getActiveValue(),
});
this._activeSlider = undefined;
}
_handleKeyUp(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
this._activeSlider = (e.currentTarget as any).id as ActiveSlider;
e.preventDefault();
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, {
value: this._getActiveValue(),
});
this._activeSlider = undefined;
}
destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
protected render(): TemplateResult {
const trackPath = arc({ x: 0, y: 0, start: 0, end: MAX_ANGLE, r: RADIUS });
const maxRatio = MAX_ANGLE / 360;
const f = RADIUS * 2 * Math.PI;
const lowValue = this.dual ? this.low : this.value;
const highValue = this.high;
const lowPercentage = this._valueToPercentage(lowValue ?? this.min);
const highPercentage = this._valueToPercentage(highValue ?? this.max);
const lowArcLength = lowPercentage * f * maxRatio;
const lowStrokeDasharray = `${lowArcLength} ${f - lowArcLength}`;
const highArcLength = (1 - highPercentage) * f * maxRatio;
const highStrokeDasharray = `${highArcLength} ${f - highArcLength}`;
const highStrokeDashOffset = `${highArcLength + f * (1 - maxRatio)}`;
const currentPercentage = this._valueToPercentage(this.current ?? 0);
const currentAngle = currentPercentage * MAX_ANGLE;
return html`
<svg
id="slider"
viewBox="0 0 320 320"
overflow="visible"
class=${classMap({
pressed: Boolean(this._activeSlider),
})}
@keydown=${this._handleKeyDown}
tabindex=${this._lastSlider ? "0" : "-1"}
>
<g
id="container"
transform="translate(160 160) rotate(${ROTATE_ANGLE})"
>
<g id="interaction">
<path d=${trackPath} />
</g>
<g id="display">
<path class="background" d=${trackPath} />
<circle
.id=${this.dual ? "low" : "value"}
class="track"
cx="0"
cy="0"
r=${RADIUS}
stroke-dasharray=${lowStrokeDasharray}
stroke-dashoffset="0"
role="slider"
tabindex="0"
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${lowValue != null
? this._steppedValue(lowValue)
: undefined}
aria-disabled=${this.disabled}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
${this.dual
? svg`
<circle
id="high"
class="track"
cx="0"
cy="0"
r=${RADIUS}
stroke-dasharray=${highStrokeDasharray}
stroke-dashoffset=${highStrokeDashOffset}
role="slider"
tabindex="0"
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
highValue != null
? this._steppedValue(highValue)
: undefined
}
aria-disabled=${this.disabled}
aria-label=${ifDefined(this.highLabel)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
`
: nothing}
${this.current != null
? svg`
<g
style=${styleMap({ "--current-angle": `${currentAngle}deg` })}
class="current"
>
<line
x1=${RADIUS - 12}
y1="0"
x2=${RADIUS - 15}
y2="0"
stroke-width="4"
/>
<line
x1=${RADIUS - 15}
y1="0"
x2=${RADIUS - 20}
y2="0"
stroke-linecap="round"
stroke-width="4"
/>
</g>
`
: nothing}
</g>
</g>
</svg>
`;
}
static get styles(): CSSResultGroup {
return css`
:host {
--control-circular-slider-color: var(--primary-color);
--control-circular-slider-background: #8b97a3;
--control-circular-slider-background-opacity: 0.3;
--control-circular-slider-low-color: var(
--control-circular-slider-color
);
--control-circular-slider-high-color: var(
--control-circular-slider-color
);
}
svg {
width: 320px;
display: block;
}
#slider {
outline: none;
}
#interaction {
display: flex;
fill: none;
stroke: transparent;
stroke-linecap: round;
stroke-width: 48px;
cursor: pointer;
}
#display {
pointer-events: none;
}
:host([disabled]) #interaction {
cursor: initial;
}
.background {
fill: none;
stroke: var(--control-circular-slider-background);
opacity: var(--control-circular-slider-background-opacity);
stroke-linecap: round;
stroke-width: 24px;
}
.track {
outline: none;
fill: none;
stroke-linecap: round;
stroke-width: 24px;
transition: stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
stroke-dashoffset 300ms ease-in-out;
}
.track:focus-visible {
stroke-width: 28px;
}
.pressed .track {
transition: stroke-width 300ms ease-in-out;
}
.current {
stroke: var(--primary-text-color);
transform: rotate(var(--current-angle, 0));
transition: transform 300ms ease-in-out;
}
#value {
stroke: var(--control-circular-slider-color);
}
#low {
stroke: var(--control-circular-slider-low-color);
}
#high {
stroke: var(--control-circular-slider-high-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-control-circular-slider": HaControlCircularSlider;
}
}

View File

@ -176,7 +176,7 @@ export class HaControlSlider extends LitElement {
this._mc = undefined;
}
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyUp);
}
private get _tenPercentStep() {

67
src/resources/svg-arc.ts Normal file
View File

@ -0,0 +1,67 @@
type Vector = [number, number];
type Matrix = [Vector, Vector];
const rotateVector = ([[a, b], [c, d]]: Matrix, [x, y]: Vector): Vector => [
a * x + b * y,
c * x + d * y,
];
const createRotateMatrix = (x: number): Matrix => [
[Math.cos(x), -Math.sin(x)],
[Math.sin(x), Math.cos(x)],
];
const addVector = ([a1, a2]: Vector, [b1, b2]: Vector): Vector => [
a1 + b1,
a2 + b2,
];
export const toRadian = (angle: number) => (angle / 180) * Math.PI;
type ArcOptions = {
x: number;
y: number;
r: number;
start: number;
end: number;
rotate?: number;
};
export const arc = (options: ArcOptions) => {
const { x, y, r, start, end, rotate = 0 } = options;
const cx = x;
const cy = y;
const rx = r;
const ry = r;
const t1 = toRadian(start);
const t2 = toRadian(end);
const delta = (t2 - t1) % (2 * Math.PI);
const phi = toRadian(rotate);
const rotMatrix = createRotateMatrix(phi);
const [sX, sY] = addVector(
rotateVector(rotMatrix, [rx * Math.cos(t1), ry * Math.sin(t1)]),
[cx, cy]
);
const [eX, eY] = addVector(
rotateVector(rotMatrix, [
rx * Math.cos(t1 + delta),
ry * Math.sin(t1 + delta),
]),
[cx, cy]
);
const fA = delta > Math.PI ? 1 : 0;
const fS = delta > 0 ? 1 : 0;
return [
"M",
sX,
sY,
"A",
rx,
ry,
(phi / (2 * Math.PI)) * 360,
fA,
fS,
eX,
eY,
].join(" ");
};