/** @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);