First version
This commit is contained in:
+312
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
@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);
|
||||
Reference in New Issue
Block a user