Add slider bar (#14094)

* Add slider bar

* a11y feedbacks

* allow undefined values

* rename variable

* Add end style

* Add vertical slider

* Allow theming

* Improve theming

* Improve colors

* fix properties

* stepped value for aria value

* use vertical instead of orientation

* Improve a11y

* force role slider
This commit is contained in:
Paul Bottein 2022-10-24 09:25:26 +02:00 committed by GitHub
parent 928f20ada5
commit 55467666f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 616 additions and 0 deletions

View File

@ -0,0 +1,3 @@
---
title: Bar Sliders
---

View File

@ -0,0 +1,169 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import "../../../../src/components/ha-bar-slider";
import "../../../../src/components/ha-card";
const sliders: {
id: string;
label: string;
mode?: "start" | "end" | "indicator";
class?: string;
}[] = [
{
id: "slider-start",
label: "Slider (start mode)",
mode: "start",
},
{
id: "slider-end",
label: "Slider (end mode)",
mode: "end",
},
{
id: "slider-indicator",
label: "Slider (indicator mode)",
mode: "indicator",
},
{
id: "slider-start-custom",
label: "Slider (start mode) and custom style",
mode: "start",
class: "custom",
},
{
id: "slider-end-custom",
label: "Slider (end mode) and custom style",
mode: "end",
class: "custom",
},
{
id: "slider-indicator-custom",
label: "Slider (indicator mode) and custom style",
mode: "indicator",
class: "custom",
},
];
@customElement("demo-components-ha-bar-slider")
export class DemoHaBarSlider extends LitElement {
@state() private value = 50;
@state() private sliderPosition?: number;
handleValueChanged(e: CustomEvent) {
this.value = e.detail.value as number;
}
handleSliderMoved(e: CustomEvent) {
this.sliderPosition = e.detail.value as number;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p><b>Slider values</b></p>
<table>
<tbody>
<tr>
<td>position</td>
<td>${this.sliderPosition ?? "-"}</td>
</tr>
<tr>
<td>value</td>
<td>${this.value ?? "-"}</td>
</tr>
</tbody>
</table>
</div>
</ha-card>
${repeat(sliders, (slider) => {
const { id, label, ...config } = slider;
return html`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-bar-slider
.value=${this.value}
.mode=${config.mode}
class=${ifDefined(config.class)}
@value-changed=${this.handleValueChanged}
@slider-moved=${this.handleSliderMoved}
aria-labelledby=${id}
>
</ha-bar-slider>
</div>
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-sliders">
${repeat(sliders, (slider) => {
const { id, label, ...config } = slider;
return html`
<ha-bar-slider
.value=${this.value}
.mode=${config.mode}
vertical
class=${ifDefined(config.class)}
@value-changed=${this.handleValueChanged}
@slider-moved=${this.handleSliderMoved}
aria-label=${label}
>
</ha-bar-slider>
`;
})}
</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;
}
label {
font-weight: 600;
}
.custom {
--slider-bar-color: #ffcf4c;
--slider-bar-background: #ffcf4c64;
--slider-bar-thickness: 100px;
--slider-bar-border-radius: 24px;
}
.vertical-sliders {
height: 300px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
p.title {
margin-bottom: 12px;
}
.vertical-sliders > *:not(:last-child) {
margin-right: 4px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-bar-slider": DemoHaBarSlider;
}
}

View File

@ -110,6 +110,7 @@
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2",
"hammerjs": "^2.0.8",
"hls.js": "^1.2.3",
"home-assistant-js-websocket": "^8.0.0",
"idb-keyval": "^5.1.3",
@ -169,6 +170,7 @@
"@types/chromecast-caf-receiver": "5.0.12",
"@types/chromecast-caf-sender": "^1.0.3",
"@types/glob": "^7",
"@types/hammerjs": "^2.0.41",
"@types/js-yaml": "^4",
"@types/leaflet": "^1",
"@types/leaflet-draw": "^1",

View File

@ -0,0 +1,426 @@
import "hammerjs";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"slider-moved": { value?: number };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
const getPercentageFromEvent = (e: HammerInput, vertical: boolean) => {
if (vertical) {
const y = e.center.y;
const offset = e.target.getBoundingClientRect().top;
const total = e.target.clientHeight;
return Math.max(Math.min(1, 1 - (y - offset) / total), 0);
}
const x = e.center.x;
const offset = e.target.getBoundingClientRect().left;
const total = e.target.clientWidth;
return Math.max(Math.min(1, (x - offset) / total), 0);
};
@customElement("ha-bar-slider")
export class HaBarSlider extends LitElement {
@property({ type: Boolean })
public disabled = false;
@property()
public mode?: "start" | "end" | "indicator" = "start";
@property({ type: Boolean })
public vertical = false;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public step = 1;
@property({ type: Number })
public min = 0;
@property({ type: Number })
public max = 100;
@property()
public label?: string;
private _mc?: HammerManager;
@property({ type: Boolean, reflect: true })
public pressed = false;
valueToPercentage(value: number) {
return (value - this.min) / (this.max - this.min);
}
percentageToValue(value: number) {
return (this.max - this.min) * value + this.min;
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
}
boundedValue(value: number) {
return Math.min(Math.max(value, this.min), this.max);
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.setupListeners();
this.setAttribute("role", "slider");
if (!this.hasAttribute("tabindex")) {
this.setAttribute("tabindex", "0");
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("value")) {
const valuenow = this.steppedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
}
if (changedProps.has("max")) {
this.setAttribute("aria-valuemax", this.max.toString());
}
if (changedProps.has("vertical")) {
const orientation = this.vertical ? "vertical" : "horizontal";
this.setAttribute("aria-orientation", orientation);
}
}
connectedCallback(): void {
super.connectedCallback();
this.setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.destroyListeners();
}
@query("#slider")
private slider;
setupListeners() {
if (this.slider && !this._mc) {
this._mc = new Hammer.Manager(this.slider, {
touchAction: this.vertical ? "pan-x" : "pan-y",
});
this._mc.add(
new Hammer.Pan({
threshold: 10,
direction: Hammer.DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Hammer.Tap({ event: "singletap" }));
let savedValue;
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
savedValue = this.value;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
this.value = savedValue;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
const percentage = getPercentageFromEvent(e, this.vertical);
this.value = this.percentageToValue(percentage);
const value = this.steppedValue(this.value);
fireEvent(this, "slider-moved", { value });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
const percentage = getPercentageFromEvent(e, this.vertical);
this.value = this.steppedValue(this.percentageToValue(percentage));
fireEvent(this, "slider-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this.value });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
const percentage = getPercentageFromEvent(e, this.vertical);
this.value = this.steppedValue(this.percentageToValue(percentage));
fireEvent(this, "value-changed", { value: this.value });
});
this.addEventListener("keydown", this._handleKeyDown);
this.addEventListener("keyup", this._handleKeyUp);
}
}
destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyDown);
}
private get _tenPercentStep() {
return Math.max(this.step, (this.max - this.min) / 10);
}
_handleKeyDown(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
switch (e.code) {
case "ArrowRight":
case "ArrowUp":
this.value = this.boundedValue((this.value ?? 0) + this.step);
break;
case "ArrowLeft":
case "ArrowDown":
this.value = this.boundedValue((this.value ?? 0) - this.step);
break;
case "PageUp":
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
break;
case "PageDown":
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
break;
case "Home":
this.value = this.min;
break;
case "End":
this.value = this.max;
break;
}
fireEvent(this, "slider-moved", { value: this.value });
}
_handleKeyUp(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
fireEvent(this, "value-changed", { value: this.value });
}
protected render(): TemplateResult {
return html`
<div
id="slider"
class="slider"
style=${styleMap({
"--value": `${this.valueToPercentage(this.value ?? 0)}`,
})}
>
<div class="slider-track-background"></div>
${this.mode === "indicator"
? html`
<div
class=${classMap({
"slider-track-indicator": true,
vertical: this.vertical,
})}
></div>
`
: html`
<div
class=${classMap({
"slider-track-bar": true,
vertical: this.vertical,
[this.mode ?? "start"]: true,
})}
></div>
`}
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
--slider-bar-color: rgb(var(--rgb-primary-color));
--slider-bar-background: rgba(var(--rgb-disabled-color), 0.2);
--slider-bar-thickness: 40px;
--slider-bar-border-radius: 12px;
height: var(--slider-bar-thickness);
width: 100%;
}
:host([vertical]) {
width: var(--slider-bar-thickness);
height: 100%;
}
.slider {
position: relative;
height: 100%;
width: 100%;
border-radius: var(--slider-bar-border-radius);
transform: translateZ(0);
overflow: hidden;
cursor: pointer;
}
.slider * {
pointer-events: none;
}
.slider .slider-track-background {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: var(--slider-bar-background);
}
.slider .slider-track-bar {
--border-radius: calc(var(--slider-bar-border-radius) / 2);
--handle-size: 4px;
--handle-margin: calc(var(--slider-bar-thickness) / 8);
position: absolute;
height: 100%;
width: 100%;
background-color: var(--slider-bar-color);
transition: transform 180ms ease-in-out;
}
.slider .slider-track-bar::after {
display: block;
content: "";
position: absolute;
margin: auto;
border-radius: var(--handle-size);
background-color: white;
}
.slider .slider-track-bar {
top: 0;
left: 0;
transform: translate3d(calc((var(--value, 0) - 1) * 100%), 0, 0);
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.slider .slider-track-bar:after {
top: 0;
bottom: 0;
right: var(--handle-margin);
height: 50%;
width: var(--handle-size);
}
.slider .slider-track-bar.end {
right: 0;
left: initial;
transform: translate3d(calc(var(--value, 0) * 100%), 0, 0);
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.slider .slider-track-bar.end::after {
right: initial;
left: var(--handle-margin);
}
.slider .slider-track-bar.vertical {
bottom: 0;
left: 0;
transform: translate3d(0, calc((1 - var(--value, 0)) * 100%), 0);
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.slider .slider-track-bar.vertical:after {
top: var(--handle-margin);
right: 0;
left: 0;
bottom: initial;
width: 50%;
height: var(--handle-size);
}
.slider .slider-track-bar.vertical.end {
top: 0;
bottom: initial;
transform: translate3d(0, calc((0 - var(--value, 0)) * 100%), 0);
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.slider .slider-track-bar.vertical.end::after {
top: initial;
bottom: var(--handle-margin);
}
.slider .slider-track-indicator:after {
display: block;
content: "";
background-color: rgb(var(--rgb-secondary-text-color));
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
border-radius: var(--handle-size);
}
.slider .slider-track-indicator {
--indicator-size: calc(var(--slider-bar-thickness) / 4);
--handle-size: 4px;
position: absolute;
background-color: white;
border-radius: var(--handle-size);
transition: left 180ms ease-in-out, bottom 180ms ease-in-out;
top: 0;
bottom: 0;
left: calc(var(--value, 0) * (100% - var(--indicator-size)));
width: var(--indicator-size);
}
.slider .slider-track-indicator:after {
height: 50%;
width: var(--handle-size);
}
.slider .slider-track-indicator.vertical {
top: initial;
right: 0;
left: 0;
bottom: calc(var(--value, 0) * (100% - var(--indicator-size)));
height: var(--indicator-size);
width: 100%;
}
.slider .slider-track-indicator.vertical:after {
height: var(--handle-size);
width: 50%;
}
:host([pressed]) .slider-track-bar,
:host([pressed]) .slider-track-indicator {
transition: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-bar-slider": HaBarSlider;
}
}

View File

@ -3816,6 +3816,13 @@ __metadata:
languageName: node
linkType: hard
"@types/hammerjs@npm:^2.0.41":
version: 2.0.41
resolution: "@types/hammerjs@npm:2.0.41"
checksum: d16fbd688fc9b18cc270abe8dea8d4c50ef7bd8375e593d92c233d299387933a6b003c8db69819344833052458bc5f9ef1b472001277a49f095928d184356006
languageName: node
linkType: hard
"@types/har-format@npm:*":
version: 1.2.4
resolution: "@types/har-format@npm:1.2.4"
@ -8823,6 +8830,13 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"hammerjs@npm:^2.0.8":
version: 2.0.8
resolution: "hammerjs@npm:2.0.8"
checksum: b092da7d1565a165d7edb53ef0ce212837a8b11f897aa3cf81a7818b66686b0ab3f4747fbce8fc8a41d1376594639ce3a054b0fd4889ca8b5b136a29ca500e27
languageName: node
linkType: hard
"handle-thing@npm:^2.0.0":
version: 2.0.0
resolution: "handle-thing@npm:2.0.0"
@ -9032,6 +9046,7 @@ fsevents@^1.2.7:
"@types/chromecast-caf-receiver": 5.0.12
"@types/chromecast-caf-sender": ^1.0.3
"@types/glob": ^7
"@types/hammerjs": ^2.0.41
"@types/js-yaml": ^4
"@types/leaflet": ^1
"@types/leaflet-draw": ^1
@ -9085,6 +9100,7 @@ fsevents@^1.2.7:
gulp-merge-json: ^1.3.1
gulp-rename: ^2.0.0
gulp-zopfli-green: ^3.0.1
hammerjs: ^2.0.8
hls.js: ^1.2.3
home-assistant-js-websocket: ^8.0.0
html-minifier: ^4.0.0