diff --git a/gallery/src/pages/components/ha-bar-slider.markdown b/gallery/src/pages/components/ha-bar-slider.markdown new file mode 100644 index 0000000000..aea201fb5c --- /dev/null +++ b/gallery/src/pages/components/ha-bar-slider.markdown @@ -0,0 +1,3 @@ +--- +title: Bar Sliders +--- diff --git a/gallery/src/pages/components/ha-bar-slider.ts b/gallery/src/pages/components/ha-bar-slider.ts new file mode 100644 index 0000000000..b35a3462e4 --- /dev/null +++ b/gallery/src/pages/components/ha-bar-slider.ts @@ -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` + +
+

Slider values

+ + + + + + + + + + + +
position${this.sliderPosition ?? "-"}
value${this.value ?? "-"}
+
+
+ ${repeat(sliders, (slider) => { + const { id, label, ...config } = slider; + return html` + +
+ +
Config: ${JSON.stringify(config)}
+ + +
+
+ `; + })} + +
+

Vertical

+
+ ${repeat(sliders, (slider) => { + const { id, label, ...config } = slider; + return html` + + + `; + })} +
+
+
+ `; + } + + 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; + } +} diff --git a/package.json b/package.json index a0e2a6e0d1..645d8fe7bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ha-bar-slider.ts b/src/components/ha-bar-slider.ts new file mode 100644 index 0000000000..a0169918ab --- /dev/null +++ b/src/components/ha-bar-slider.ts @@ -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` +
+
+ ${this.mode === "indicator" + ? html` +
+ ` + : html` +
+ `} +
+ `; + } + + 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; + } +} diff --git a/yarn.lock b/yarn.lock index c3d1701367..c3cfcff482 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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