import { DIRECTION_ALL, Manager, Pan, Press, Tap } from "@egjs/hammerjs"; import type { PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, nothing } 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 { formatNumber } from "../common/number/format_number"; import { blankBeforeUnit } from "../common/translations/blank_before_unit"; import type { FrontendLocaleData } from "../data/translation"; declare global { interface HASSDomEvents { "slider-moved": { value?: number }; } } const A11Y_KEY_CODES = new Set([ "ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "PageUp", "PageDown", "Home", "End", ]); type TooltipPosition = "top" | "bottom" | "left" | "right"; type TooltipMode = "never" | "always" | "interaction"; type SliderMode = "start" | "end" | "cursor"; @customElement("ha-control-slider") export class HaControlSlider extends LitElement { @property({ attribute: false }) public locale?: FrontendLocaleData; @property({ type: Boolean, reflect: true }) public disabled = false; @property() public mode?: SliderMode = "start"; @property({ type: Boolean, reflect: true }) public vertical = false; @property({ type: Boolean, attribute: "show-handle" }) public showHandle = false; @property({ type: Boolean, attribute: "inverted" }) public inverted = false; @property({ attribute: "tooltip-position" }) public tooltipPosition?: TooltipPosition; @property() public unit?: string; @property({ attribute: "tooltip-mode" }) public tooltipMode: TooltipMode = "interaction"; @property({ attribute: "touch-action" }) public touchAction?: string; @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({ type: String }) public label?: string; @state() public pressed = false; @state() public tooltipVisible = false; private _mc?: HammerManager; valueToPercentage(value: number) { const percentage = (this.boundedValue(value) - this.min) / (this.max - this.min); return this.inverted ? 1 - percentage : percentage; } percentageToValue(percentage: number) { return ( (this.max - this.min) * (this.inverted ? 1 - percentage : percentage) + 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(); } protected updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has("value")) { const valuenow = this.steppedValue(this.value ?? 0); this.setAttribute("aria-valuenow", valuenow.toString()); this.setAttribute("aria-valuetext", this._formatValue(valuenow)); } 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 Manager(this.slider, { touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"), }); this._mc.add( new Pan({ threshold: 10, direction: DIRECTION_ALL, enable: true, }) ); this._mc.add(new Tap({ event: "singletap" })); this._mc.add(new Press()); let savedValue; this._mc.on("panstart", () => { if (this.disabled) return; this.pressed = true; this._showTooltip(); savedValue = this.value; }); this._mc.on("pancancel", () => { if (this.disabled) return; this.pressed = false; this._hideTooltip(); this.value = savedValue; }); this._mc.on("panmove", (e) => { if (this.disabled) return; const percentage = this._getPercentageFromEvent(e); 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; this._hideTooltip(); const percentage = this._getPercentageFromEvent(e); this.value = this.steppedValue(this.percentageToValue(percentage)); fireEvent(this, "slider-moved", { value: undefined }); fireEvent(this, "value-changed", { value: this.value }); }); this._mc.on("singletap pressup", (e) => { if (this.disabled) return; const percentage = this._getPercentageFromEvent(e); this.value = this.steppedValue(this.percentageToValue(percentage)); fireEvent(this, "value-changed", { value: this.value }); }); } } destroyListeners() { if (this._mc) { this._mc.destroy(); this._mc = undefined; } } private get _tenPercentStep() { return Math.max(this.step, (this.max - this.min) / 10); } private _showTooltip() { if (this._tooltipTimeout != null) window.clearTimeout(this._tooltipTimeout); this.tooltipVisible = true; } private _hideTooltip(delay?: number) { if (!delay) { this.tooltipVisible = false; return; } this._tooltipTimeout = window.setTimeout(() => { this.tooltipVisible = false; }, delay); } private _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; } this._showTooltip(); fireEvent(this, "slider-moved", { value: this.value }); } private _tooltipTimeout?: number; private _handleKeyUp(e: KeyboardEvent) { if (!A11Y_KEY_CODES.has(e.code)) return; e.preventDefault(); this._hideTooltip(500); fireEvent(this, "value-changed", { value: this.value }); } private _getPercentageFromEvent = (e: HammerInput) => { if (this.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); }; private _formatValue(value: number) { const formattedValue = formatNumber(value, this.locale); const formattedUnit = this.unit ? `${blankBeforeUnit(this.unit, this.locale)}${this.unit}` : ""; return `${formattedValue}${formattedUnit}`; } private _renderTooltip() { if (this.tooltipMode === "never") return nothing; const position = this.tooltipPosition ?? (this.vertical ? "left" : "top"); const visible = this.tooltipMode === "always" || (this.tooltipVisible && this.tooltipMode === "interaction"); const value = this.steppedValue(this.value ?? 0); return html` `; } protected render(): TemplateResult { const valuenow = this.steppedValue(this.value ?? 0); return html`