@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
JavaScript
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);