313 lines
8.4 KiB
JavaScript
313 lines
8.4 KiB
JavaScript
/**
|
|
@license
|
|
Copyright (c) 2026 trading_peter
|
|
This program is available under Apache License Version 2.0
|
|
*/
|
|
|
|
import { LitElement, html, css } from 'lit';
|
|
import { FormElement } from '@tp/helpers/form-element.js';
|
|
import { EventHelpers } from '@tp/helpers/event-helpers.js';
|
|
import { DomQuery } from '@tp/helpers/dom-query.js';
|
|
|
|
class TpSlider extends FormElement(EventHelpers(DomQuery(LitElement))) {
|
|
static get styles() {
|
|
return [
|
|
css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
.wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--tp-slider-gap, 8px);
|
|
}
|
|
|
|
/* Full-width interactive area — tall enough to contain the thumb */
|
|
.track-area {
|
|
flex: 1;
|
|
position: relative;
|
|
height: var(--tp-slider-thumb-size, 14px);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
outline: none;
|
|
}
|
|
|
|
:host([disabled]) .track-area {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* The visual track bar, centered vertically */
|
|
.track {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
height: var(--tp-slider-track-height, 4px);
|
|
background: var(--tp-slider-track-color, #ccc);
|
|
border-radius: var(--tp-slider-track-radius, 2px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Filled portion of the track */
|
|
.track-fill {
|
|
height: 100%;
|
|
background: var(--tp-slider-fill-color, var(--tp-slider-thumb-color, currentColor));
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Thumb circle */
|
|
.thumb {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: var(--tp-slider-thumb-size, 14px);
|
|
height: var(--tp-slider-thumb-size, 14px);
|
|
border-radius: 50%;
|
|
background: var(--tp-slider-thumb-color, currentColor);
|
|
pointer-events: none;
|
|
transition: box-shadow 0.1s;
|
|
}
|
|
|
|
.track-area:focus-visible .thumb {
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--tp-slider-thumb-color, currentColor) 35%, transparent);
|
|
}
|
|
|
|
.value-label {
|
|
min-width: var(--tp-slider-label-width, 36px);
|
|
text-align: right;
|
|
font-size: inherit;
|
|
color: inherit;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
[hidden] {
|
|
display: none !important;
|
|
}
|
|
`
|
|
];
|
|
}
|
|
|
|
render() {
|
|
const { disabled, _internValue, showValue, suffix } = this;
|
|
const pct = this._toPct(_internValue);
|
|
|
|
return html`
|
|
<div class="wrap" part="wrap">
|
|
<div
|
|
id="track"
|
|
class="track-area"
|
|
part="track-area"
|
|
tabindex=${disabled ? '-1' : '0'}
|
|
role="slider"
|
|
aria-valuenow=${_internValue}
|
|
aria-valuemin=${this.min}
|
|
aria-valuemax=${this.max}
|
|
aria-disabled=${disabled || false}
|
|
@mousedown=${this._onTrackDown}
|
|
@touchstart=${this._onTrackTouchStart}
|
|
@keydown=${this._onKeyDown}
|
|
>
|
|
<div class="track" part="track">
|
|
<div class="track-fill" part="track-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<div class="thumb" part="thumb" style="left:${pct}%"></div>
|
|
</div>
|
|
<div class="value-label" part="value-label" ?hidden=${!showValue}>${_internValue}${suffix || ''}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
static get properties() {
|
|
return {
|
|
disabled: { type: Boolean, reflect: true },
|
|
step: { type: Number },
|
|
value: { type: String },
|
|
min: { type: Number },
|
|
max: { type: Number },
|
|
|
|
// String to add after the number in the label. E.g. `13` becomes `13px`.
|
|
suffix: { type: String },
|
|
|
|
// If true don't include the suffix in the `value` property.
|
|
ignoreSuffix: { type: Boolean },
|
|
|
|
// If true, show the current value next to the slider.
|
|
showValue: { type: Boolean },
|
|
|
|
_internValue: { type: String },
|
|
_floatingPoint: { type: Boolean, state: true }
|
|
};
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._internValue = '0';
|
|
this.value = '0';
|
|
this.step = 1;
|
|
this.min = 0;
|
|
this.max = 100;
|
|
this._floatingPoint = false;
|
|
this._isConnected = false;
|
|
this._dragging = false;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
// Set small timeout to ensure all initial updates have run
|
|
setTimeout(() => {
|
|
this._isConnected = true;
|
|
}, 0);
|
|
}
|
|
|
|
updated(changes) {
|
|
super.updated();
|
|
|
|
if (changes.has('step')) {
|
|
// Automatically enable floating point mode if step is fractional
|
|
this._floatingPoint = this.step % 1 !== 0;
|
|
}
|
|
|
|
if (changes.has('value') || changes.has('min') || changes.has('max') || changes.has('suffix') || changes.has('ignoreSuffix')) {
|
|
this._updateValue(this.value, true);
|
|
}
|
|
}
|
|
|
|
// ── Pointer interaction ──
|
|
|
|
_onTrackDown(e) {
|
|
if (this.disabled) return;
|
|
e.preventDefault();
|
|
this._dragging = true;
|
|
this._updateFromClientX(e.clientX);
|
|
this.listen(window, 'mousemove', '_onWindowMove');
|
|
this.listen(window, 'mouseup', '_onWindowUp');
|
|
}
|
|
|
|
_onTrackTouchStart(e) {
|
|
if (this.disabled) return;
|
|
e.preventDefault();
|
|
this._dragging = true;
|
|
this._updateFromClientX(e.touches[0].clientX);
|
|
this.listen(window, 'touchmove', '_onWindowTouchMove');
|
|
this.listen(window, 'touchend', '_onWindowUp');
|
|
}
|
|
|
|
_onWindowMove(e) {
|
|
if (!this._dragging) return;
|
|
this._updateFromClientX(e.clientX);
|
|
}
|
|
|
|
_onWindowTouchMove(e) {
|
|
if (!this._dragging) return;
|
|
this._updateFromClientX(e.touches[0].clientX);
|
|
}
|
|
|
|
_onWindowUp() {
|
|
if (!this._dragging) return;
|
|
this._dragging = false;
|
|
this.unlisten(window, 'mousemove', '_onWindowMove');
|
|
this.unlisten(window, 'mouseup', '_onWindowUp');
|
|
this.unlisten(window, 'touchmove', '_onWindowTouchMove');
|
|
this.unlisten(window, 'touchend', '_onWindowUp');
|
|
}
|
|
|
|
_updateFromClientX(clientX) {
|
|
const rect = this.$.track.getBoundingClientRect();
|
|
let pct = (clientX - rect.left) / rect.width;
|
|
pct = Math.max(0, Math.min(1, pct));
|
|
const raw = this.min + pct * (this.max - this.min);
|
|
const stepped = Math.round(raw / this.step) * this.step;
|
|
this._updateValue(stepped);
|
|
}
|
|
|
|
// ── Keyboard interaction ──
|
|
|
|
_onKeyDown(e) {
|
|
let val = this._parseValue(this._internValue);
|
|
let changed = true;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
val = Math.min(this.max, val + this.step);
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
val = Math.max(this.min, val - this.step);
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
val = this.min;
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
val = this.max;
|
|
break;
|
|
default:
|
|
changed = false;
|
|
}
|
|
|
|
if (changed) {
|
|
if (this._floatingPoint) val = this._formatFloatingValue(val);
|
|
this._updateValue(val);
|
|
}
|
|
}
|
|
|
|
// ── Value helpers ──
|
|
|
|
_toPct(internValue) {
|
|
const range = this.max - this.min;
|
|
if (range === 0) return 0;
|
|
return Math.max(0, Math.min(100, (parseFloat(internValue) - this.min) / range * 100));
|
|
}
|
|
|
|
_parseValue(val) {
|
|
if (this._floatingPoint) {
|
|
return parseFloat(val);
|
|
}
|
|
return parseInt(val, 10);
|
|
}
|
|
|
|
_formatFloatingValue(val) {
|
|
if (!this._floatingPoint) {
|
|
return val;
|
|
}
|
|
const stepStr = this.step.toString();
|
|
const decimalPlaces = stepStr.includes('.') ? stepStr.split('.')[1].length : 0;
|
|
return parseFloat(val.toFixed(decimalPlaces));
|
|
}
|
|
|
|
_updateValue(val, skipEvent) {
|
|
const oldValue = this.value;
|
|
|
|
val = this._parseValue(val);
|
|
|
|
if (isNaN(val)) {
|
|
val = this.min;
|
|
}
|
|
|
|
val = Math.min(this.max, val);
|
|
val = Math.max(this.min, val);
|
|
|
|
if (this._floatingPoint) {
|
|
val = this._formatFloatingValue(val);
|
|
}
|
|
|
|
this._internValue = String(val);
|
|
this.value = this.ignoreSuffix ? String(val) : (!!this.suffix ? val + this.suffix : String(val));
|
|
|
|
if (oldValue !== this.value && this._isConnected) {
|
|
if (!skipEvent) this.dispatchEvent(new CustomEvent('slider-changed', { detail: { value: this.value }, bubbles: true, composed: true }));
|
|
}
|
|
}
|
|
}
|
|
|
|
window.customElements.define('tp-slider', TpSlider);
|