UNPKG

@thomasloven/round-slider

Version:

A simple round slider webcomponent - [demo](https://rawcdn.githack.com/thomasloven/round-slider/master/example.html)

532 lines (530 loc) 18.2 kB
import { __decorate } from "tslib"; import { LitElement, html, css, svg, } from "lit"; import { property, state } from "lit/decorators.js"; export class RoundSlider extends LitElement { constructor() { super(); this.min = 0; this.max = 100; this.step = 1; this.startAngle = 135; this.arcLength = 270; this.handleSize = 6; this.handleZoom = 1.5; this.readonly = false; this.disabled = false; this.dragging = false; this.rtl = false; this.outside = false; this._scale = 1; this.dragEnd = this.dragEnd.bind(this); this.drag = this.drag.bind(this); this._keyStep = this._keyStep.bind(this); } connectedCallback() { super.connectedCallback(); document.addEventListener("mouseup", this.dragEnd); document.addEventListener("touchend", this.dragEnd, { passive: false, }); document.addEventListener("mousemove", this.drag); document.addEventListener("touchmove", this.drag, { passive: false, }); document.addEventListener("keydown", this._keyStep); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener("mouseup", this.dragEnd); document.removeEventListener("touchend", this.dragEnd); document.removeEventListener("mousemove", this.drag); document.removeEventListener("touchmove", this.drag); document.removeEventListener("keydown", this._keyStep); } get _start() { return (this.startAngle * Math.PI) / 180; } get _len() { // Things get weird if length is more than a complete turn return Math.min((this.arcLength * Math.PI) / 180, 2 * Math.PI - 0.01); } get _end() { return this._start + this._len; } get _showHandle() { // If handle is shown if (this.readonly) return false; if (this.value == null && (this.high == null || this.low == null)) return false; return true; } _angleInside(angle) { // Check if an angle is on the arc let a = ((this.startAngle + this.arcLength / 2 - angle + 180 + 360) % 360) - 180; return a < this.arcLength / 2 && a > -this.arcLength / 2; } _angle2xy(angle) { if (this.rtl) return { x: -Math.cos(angle), y: Math.sin(angle) }; return { x: Math.cos(angle), y: Math.sin(angle) }; } _xy2angle(x, y) { if (this.rtl) x = -x; return (Math.atan2(y, x) - this._start + 8 * Math.PI) % (2 * Math.PI); } _value2angle(value) { value = Math.min(this.max, Math.max(this.min, value)); const fraction = (value - this.min) / (this.max - this.min); return this._start + fraction * this._len; } _angle2value(angle) { return (Math.round(((angle / this._len) * (this.max - this.min) + this.min) / this.step) * this.step); } get _boundaries() { // Get the maximum extents of the bar arc const start = this._angle2xy(this._start); const end = this._angle2xy(this._end); let up = 1; if (!this._angleInside(270)) up = Math.max(-start.y, -end.y); let down = 1; if (!this._angleInside(90)) down = Math.max(start.y, end.y); let left = 1; if (!this._angleInside(180)) left = Math.max(-start.x, -end.x); let right = 1; if (!this._angleInside(0)) right = Math.max(start.x, end.x); return { up, down, left, right, height: up + down, width: left + right, }; } _mouse2value(ev) { const mouseX = ev.type.startsWith("touch") ? ev.touches[0].clientX : ev.clientX; const mouseY = ev.type.startsWith("touch") ? ev.touches[0].clientY : ev.clientY; const rect = this.shadowRoot.querySelector("svg").getBoundingClientRect(); const boundaries = this._boundaries; const x = mouseX - (rect.left + (boundaries.left * rect.width) / boundaries.width); const y = mouseY - (rect.top + (boundaries.up * rect.height) / boundaries.height); const angle = this._xy2angle(x, y); const pos = this._angle2value(angle); return pos; } dragStart(ev) { if (!this._showHandle || this.disabled) return; let handle = ev.target; let cooldown = undefined; // Avoid double events mouseDown->focus if (this._rotation && this._rotation.type !== "focus") return; // If the bar was touched, find the nearest handle and drag from that if (handle.classList.contains("shadowpath")) { if (ev.type === "touchstart") cooldown = window.setTimeout(() => { if (this._rotation) this._rotation.cooldown = undefined; }, 200); if (this.low == null) { handle = this.shadowRoot.querySelector("#value"); } else { const mouse = this._mouse2value(ev); if (Math.abs(mouse - this.low) < Math.abs(mouse - this.high)) { handle = this.shadowRoot.querySelector("#low"); } else { handle = this.shadowRoot.querySelector("#high"); } } } // If an invisible handle was clicked, switch to the visible counterpart if (handle.classList.contains("overflow")) handle = handle.nextElementSibling; if (!handle.classList.contains("handle")) return; handle.setAttribute("stroke-width", String(2 * this.handleSize * this.handleZoom * this._scale)); const min = handle.id === "high" ? this.low : this.min; const max = handle.id === "low" ? this.high : this.max; this._rotation = { handle, min, max, start: this[handle.id], type: ev.type, cooldown, }; this.dragging = true; } _cleanupRotation() { const handle = this._rotation.handle; handle.setAttribute("stroke-width", String(2 * this.handleSize * this._scale)); this._rotation = undefined; this.dragging = false; handle.blur(); } dragEnd(_ev) { if (!this._showHandle || this.disabled) return; if (!this._rotation) return; const handle = this._rotation.handle; this._cleanupRotation(); let event = new CustomEvent("value-changed", { detail: { [handle.id]: this[handle.id], }, bubbles: true, composed: true, }); this.dispatchEvent(event); // This makes the low handle render over the high handle if they both are // close to the top end. Otherwise if would be unclickable, and the high // handle locked by the low. Calcualtion is done in the dragEnd handler to // avoid "z fighting" while dragging. if (this.low && this.low >= 0.99 * this.max) this._reverseOrder = true; else this._reverseOrder = false; } drag(ev) { if (!this._showHandle || this.disabled) return; if (!this._rotation) return; if (this._rotation.cooldown) { window.clearTimeout(this._rotation.cooldown); this._cleanupRotation(); return; } if (this._rotation.type === "focus") return; ev.preventDefault(); const pos = this._mouse2value(ev); this._dragpos(pos); } _dragpos(pos) { if (pos < this._rotation.min || pos > this._rotation.max) return; const handle = this._rotation.handle; this[handle.id] = pos; let event = new CustomEvent("value-changing", { detail: { [handle.id]: pos, }, bubbles: true, composed: true, }); this.dispatchEvent(event); } _keyStep(ev) { if (!this._showHandle || this.disabled) return; if (!this._rotation) return; const handle = this._rotation.handle; if (ev.key === "ArrowLeft" || ev.key === "ArrowDown") { ev.preventDefault(); if (this.rtl) this._dragpos(this[handle.id] + this.step); else this._dragpos(this[handle.id] - this.step); } if (ev.key === "ArrowRight" || ev.key === "ArrowUp") { ev.preventDefault(); if (this.rtl) this._dragpos(this[handle.id] - this.step); else this._dragpos(this[handle.id] + this.step); } if (ev.key === "Home") { ev.preventDefault(); this._dragpos(this.min); } if (ev.key === "End") { ev.preventDefault(); this._dragpos(this.max); } } updated(changedProperties) { // Adjust margin in the bar slider stroke width is greater than the handle size if (this.shadowRoot.querySelector(".slider")) { const styles = window.getComputedStyle(this.shadowRoot.querySelector(".slider")); if (styles && styles["strokeWidth"]) { const stroke = parseFloat(styles["strokeWidth"]); if (stroke > this.handleSize * this.handleZoom) { const view = this._boundaries; const margin = ` ${(stroke / 2) * Math.abs(view.up)}px ${(stroke / 2) * Math.abs(view.right)}px ${(stroke / 2) * Math.abs(view.down)}px ${(stroke / 2) * Math.abs(view.left)}px`; this.shadowRoot.querySelector("svg").style.margin = margin; } } } // Workaround for vector-effect not working in IE and pre-Chromium Edge // That's also why the _scale property exists if (this.shadowRoot.querySelector("svg") && // @ts-expect-error this.shadowRoot.querySelector("svg").style.vectorEffect === undefined) { if (changedProperties.has("_scale") && this._scale != 1) { this.shadowRoot .querySelector("svg") .querySelectorAll("path") .forEach((e) => { if (e.getAttribute("stroke-width")) return; const orig = parseFloat(getComputedStyle(e).getPropertyValue("stroke-width")); e.style.strokeWidth = `${orig * this._scale}px`; }); } const rect = this.shadowRoot.querySelector("svg").getBoundingClientRect(); const scale = Math.max(rect.width, rect.height); this._scale = 2 / scale; } } _renderArc(start, end) { const diff = end - start; const startXY = this._angle2xy(start); const endXY = this._angle2xy(end + 0.001); // Safari doesn't like arcs with no length return ` M ${startXY.x} ${startXY.y} A 1 1, 0, ${diff > Math.PI ? "1" : "0"} ${this.rtl ? "0" : "1"}, ${endXY.x} ${endXY.y} `; } _renderHandle(id) { const theta = this._value2angle(this[id]); const pos = this._angle2xy(theta); const label = { value: this.valueLabel, low: this.lowLabel, high: this.highLabel, }[id] || ""; // Two handles are drawn. One visible, and one invisible that's twice as // big. Makes it easier to click. return svg ` <g class="${id} handle"> <path id=${id} class="overflow" d=" M ${pos.x} ${pos.y} L ${pos.x + 0.001} ${pos.y + 0.001} " vector-effect="non-scaling-stroke" stroke="rgba(0,0,0,0)" stroke-width="${4 * this.handleSize * this._scale}" /> <path id=${id} class="handle" d=" M ${pos.x} ${pos.y} L ${pos.x + 0.001} ${pos.y + 0.001} " vector-effect="non-scaling-stroke" stroke-width="${2 * this.handleSize * this._scale}" tabindex="0" @focus=${this.dragStart} @blur=${this.dragEnd} role="slider" aria-valuemin=${this.min} aria-valuemax=${this.max} aria-valuenow=${this[id]} aria-disabled=${this.disabled} aria-label=${label || ""} /> </g> `; } render() { const view = this._boundaries; return html ` <svg @mousedown=${this.dragStart} @touchstart=${this.dragStart} xmln="http://www.w3.org/2000/svg" viewBox="${-view.left} ${-view.up} ${view.width} ${view.height}" style="margin: ${this.handleSize * this.handleZoom}px;" ?disabled=${this.disabled} focusable="false" > <g class="slider"> <path class="path" d=${this._renderArc(this._start, this._end)} vector-effect="non-scaling-stroke" /> <g class="bar"> ${this.low != null && this.high != null && this.outside ? svg ` <path class="bar low" vector-effect="non-scaling-stroke" d=${this._renderArc(this._value2angle(this.min), this._value2angle(this.low))} /> <path class="bar high" vector-effect="non-scaling-stroke" d=${this._renderArc(this._value2angle(this.high), this._value2angle(this.max))} /> ` : svg ` <path class="bar" vector-effect="non-scaling-stroke" d=${this._renderArc(this._value2angle(this.low != null ? this.low : this.min), this._value2angle(this.high != null ? this.high : this.value))} /> `} </g> <path class="shadowpath" d=${this._renderArc(this._start, this._end)} vector-effect="non-scaling-stroke" stroke="rgba(0,0,0,0)" stroke-width="${3 * this.handleSize * this._scale}" stroke-linecap="butt" /> </g> <g class="handles"> ${this._showHandle ? this.low != null ? this._reverseOrder ? svg `${this._renderHandle("high")} ${this._renderHandle("low")}` : svg `${this._renderHandle("low")} ${this._renderHandle("high")}` : svg `${this._renderHandle("value")}` : ``} </g> </svg> `; } static get styles() { return css ` :host { display: inline-block; width: 100%; } svg { overflow: visible; display: block; } path { transition: stroke 1s ease-out, stroke-width 200ms ease-out; } .slider { fill: none; stroke-width: var(--round-slider-path-width, 3); stroke-linecap: var(--round-slider-linecap, round); } .path { stroke: var(--round-slider-path-color, lightgray); } g.bar { stroke: var(--round-slider-bar-color, deepskyblue); } .bar.low { stroke: var(--round-slider-low-bar-color); } .bar.high { stroke: var(--round-slider-high-bar-color); } svg[disabled] .bar { stroke: var(--round-slider-disabled-bar-color, darkgray); } g.handles { stroke: var( --round-slider-handle-color, var(--round-slider-bar-color, deepskyblue) ); stroke-linecap: round; cursor: var(--round-slider-handle-cursor, pointer); } g.low.handle { stroke: var(--round-slider-low-handle-color); } g.high.handle { stroke: var(--round-slider-high-handle-color); } svg[disabled] g.handles { stroke: var(--round-slider-disabled-bar-color, darkgray); } .handle:focus { outline: unset; } `; } } __decorate([ property({ type: Number }) ], RoundSlider.prototype, "value", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "high", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "low", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "min", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "max", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "step", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "startAngle", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "arcLength", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "handleSize", void 0); __decorate([ property({ type: Number }) ], RoundSlider.prototype, "handleZoom", void 0); __decorate([ property({ type: Boolean }) ], RoundSlider.prototype, "readonly", void 0); __decorate([ property({ type: Boolean }) ], RoundSlider.prototype, "disabled", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], RoundSlider.prototype, "dragging", void 0); __decorate([ property({ type: Boolean }) ], RoundSlider.prototype, "rtl", void 0); __decorate([ property() ], RoundSlider.prototype, "valueLabel", void 0); __decorate([ property() ], RoundSlider.prototype, "lowLabel", void 0); __decorate([ property() ], RoundSlider.prototype, "highLabel", void 0); __decorate([ property({ type: Boolean }) ], RoundSlider.prototype, "outside", void 0); __decorate([ state() ], RoundSlider.prototype, "_scale", void 0); customElements.define("round-slider", RoundSlider);