/**
@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`
${_internValue}${suffix || ''}
`;
}
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);