import { css, html } from "lit"; import { BaseElement } from "../../core/base-element.ts"; export type SliderOrientation = "horizontal" | "vertical"; /** * CTSlider - Range input slider for value selection * * @element ct-slider * * @attr {number} value - Current slider value * @attr {number} min - Minimum allowed value (default: 0) * @attr {number} max - Maximum allowed value (default: 100) * @attr {number} step - Value increment/decrement step (default: 1) * @attr {boolean} disabled - Whether the slider is disabled * @attr {SliderOrientation} orientation - Slider orientation ("horizontal" | "vertical") * * @fires ct-change - Fired when value changes with detail: { value, oldValue } * @fires ct-input - Fired during dragging with detail: { value, oldValue } * * @example * * * */ export class CTSlider extends BaseElement { static override properties = { value: { type: Number }, min: { type: Number }, max: { type: Number }, step: { type: Number }, disabled: { type: Boolean, reflect: true }, orientation: { type: String, reflect: true }, }; static override styles = css` :host { display: inline-block; width: 100%; min-width: 200px; /* Default color values if not provided */ --background: #ffffff; --foreground: #0f172a; --border: #e2e8f0; --ring: #94a3b8; --primary: #3b82f6; --primary-foreground: #ffffff; --muted: #f8fafc; --muted-foreground: #64748b; /* Slider dimensions */ --slider-height: 1.25rem; --track-height: 0.5rem; --thumb-size: 1.25rem; --slider-border-radius: 9999px; } :host([orientation="vertical"]) { width: var(--slider-height); height: 200px; min-width: var(--slider-height); min-height: 200px; } * { box-sizing: border-box; } .slider { position: relative; width: 100%; height: var(--slider-height); display: flex; align-items: center; touch-action: none; user-select: none; } .slider.vertical { width: var(--slider-height); height: 100%; align-items: center; justify-content: center; } .slider.disabled { opacity: 0.5; cursor: not-allowed; } /* Track */ .track { position: relative; width: 100%; height: var(--track-height); background-color: var(--border); border-radius: var(--slider-border-radius); overflow: hidden; cursor: pointer; } .slider.vertical .track { width: var(--track-height); height: 100%; } .slider.disabled .track { cursor: not-allowed; } /* Range (filled portion) */ .range { position: absolute; height: 100%; background-color: var(--primary); border-radius: var(--slider-border-radius); pointer-events: none; } .slider.horizontal .range { left: 0; top: 0; } .slider.vertical .range { bottom: 0; left: 0; width: 100%; } /* Thumb */ .thumb { position: absolute; width: var(--thumb-size); height: var(--thumb-size); background-color: var(--background); border: 2px solid var(--primary); border-radius: var(--slider-border-radius); cursor: grab; transform: translate(-50%, -50%); transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); } .slider.horizontal .thumb { top: 50%; } .slider.vertical .thumb { left: 50%; transform: translate(-50%, 50%); } .slider.disabled .thumb { cursor: not-allowed; border-color: var(--border); } /* Hover state */ :host(:not([disabled]):hover) .thumb { border-color: var(--primary); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); } /* Focus state */ :host(:focus) { outline: none; } :host(:focus-visible) .thumb { outline: 2px solid transparent; outline-offset: 2px; box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring); } /* Active/dragging state */ :host(.dragging) .thumb, .thumb:active { cursor: grabbing; transform: translate(-50%, -50%) scale(1.1); } .slider.vertical .thumb:active, :host(.dragging) .slider.vertical .thumb { transform: translate(-50%, 50%) scale(1.1); } /* Touch target enhancement */ .thumb::before { content: ""; position: absolute; top: 50%; left: 50%; width: 2.5rem; height: 2.5rem; transform: translate(-50%, -50%); } /* Transitions */ .range { transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1); } .slider.vertical .range { transition: height 150ms cubic-bezier(0.4, 0, 0.2, 1); } /* Smooth thumb movement during drag */ :host(.dragging) .thumb { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); } /* High contrast mode support */ @media (prefers-contrast: high) { .track { border: 1px solid; } .thumb { border-width: 3px; } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .thumb, .range { transition: none; } } /* Dark mode support (when CSS variables are updated) */ @media (prefers-color-scheme: dark) { :host { --background: #0f172a; --foreground: #f8fafc; --border: #334155; --ring: #64748b; --primary: #60a5fa; --primary-foreground: #0f172a; --muted: #1e293b; --muted-foreground: #94a3b8; } } `; declare value: number; declare min: number; declare max: number; declare step: number; declare disabled: boolean; declare orientation: SliderOrientation; private _trackElement: HTMLElement | null = null; private _thumbElement: HTMLElement | null = null; private _rangeElement: HTMLElement | null = null; constructor() { super(); this.value = 50; this.min = 0; this.max = 100; this.step = 1; this.disabled = false; this.orientation = "horizontal"; } get trackElement(): HTMLElement | null { if (!this._trackElement) { this._trackElement = this.shadowRoot?.querySelector(".track") as HTMLElement || null; } return this._trackElement; } get thumbElement(): HTMLElement | null { if (!this._thumbElement) { this._thumbElement = this.shadowRoot?.querySelector(".thumb") as HTMLElement || null; } return this._thumbElement; } get rangeElement(): HTMLElement | null { if (!this._rangeElement) { this._rangeElement = this.shadowRoot?.querySelector(".range") as HTMLElement || null; } return this._rangeElement; } private _isDragging = false; override connectedCallback() { super.connectedCallback(); // Ensure value is within bounds and snapped to step this.value = this._snapToStep(this._clampValue(this.value)); // Set up ARIA attributes this.setAttribute("role", "slider"); this.tabIndex = this.disabled ? -1 : 0; this._updateAriaAttributes(); // Add keyboard event listener this.addEventListener("keydown", this._handleKeyDown); } override disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("keydown", this._handleKeyDown); // Remove document listeners if dragging if (this._isDragging) { this._stopDragging(); } } override updated( changedProperties: Map, ) { super.updated(changedProperties); if (changedProperties.has("disabled")) { this.tabIndex = this.disabled ? -1 : 0; } if ( changedProperties.has("min") || changedProperties.has("max") || changedProperties.has("step") ) { // Re-clamp and snap the value when constraints change const clampedValue = this._snapToStep(this._clampValue(this.value)); if (clampedValue !== this.value) { this.value = clampedValue; } } if ( changedProperties.has("value") || changedProperties.has("min") || changedProperties.has("max") || changedProperties.has("disabled") || changedProperties.has("orientation") ) { this._updateAriaAttributes(); this._updateSliderPosition(); } } override firstUpdated() { // Cache references this._trackElement = this.shadowRoot?.querySelector(".track") as HTMLElement || null; this._thumbElement = this.shadowRoot?.querySelector(".thumb") as HTMLElement || null; this._rangeElement = this.shadowRoot?.querySelector(".range") as HTMLElement || null; this._updateSliderPosition(); } override render() { const sliderClasses = { "slider": true, [this.orientation]: true, "disabled": this.disabled, }; const classString = Object.entries(sliderClasses) .filter(([_, value]) => value) .map(([key]) => key) .join(" "); return html`
`; } private _clampValue(value: number): number { return Math.min(Math.max(value, this.min), this.max); } private _snapToStep(value: number): number { const steps = Math.round((value - this.min) / this.step); return this.min + steps * this.step; } private _getPercentage(): number { const range = this.max - this.min; return range > 0 ? ((this.value - this.min) / range) * 100 : 0; } private _updateSliderPosition(): void { if (!this.thumbElement || !this.rangeElement) return; const percentage = this._getPercentage(); if (this.orientation === "horizontal") { this.thumbElement.style.left = `${percentage}%`; this.thumbElement.style.top = ""; this.rangeElement.style.width = `${percentage}%`; this.rangeElement.style.height = ""; } else { // For vertical sliders, 0% is at the bottom this.thumbElement.style.bottom = `${percentage}%`; this.thumbElement.style.left = ""; this.thumbElement.style.top = ""; this.rangeElement.style.height = `${percentage}%`; this.rangeElement.style.width = ""; } } private _updateAriaAttributes() { this.setAttribute("aria-valuemin", this.min.toString()); this.setAttribute("aria-valuemax", this.max.toString()); this.setAttribute("aria-valuenow", this.value.toString()); this.setAttribute("aria-disabled", this.disabled.toString()); this.setAttribute("aria-orientation", this.orientation); } private _handleTrackMouseDown = (event: MouseEvent): void => { if (this.disabled) return; event.preventDefault(); this._updateValueFromPosition(event.clientX, event.clientY); this._startDragging(); }; private _handleTrackTouchStart = (event: TouchEvent): void => { if (this.disabled) return; event.preventDefault(); const touch = event.touches[0]; this._updateValueFromPosition(touch.clientX, touch.clientY); this._startDragging(); }; private _handleThumbMouseDown = (event: MouseEvent): void => { if (this.disabled) return; event.preventDefault(); event.stopPropagation(); this._startDragging(); }; private _handleThumbTouchStart = (event: TouchEvent): void => { if (this.disabled) return; event.preventDefault(); event.stopPropagation(); this._startDragging(); }; private _startDragging(): void { this._isDragging = true; document.addEventListener("mousemove", this._handleMouseMove); document.addEventListener("mouseup", this._handleMouseUp); document.addEventListener("touchmove", this._handleTouchMove, { passive: false, }); document.addEventListener("touchend", this._handleTouchEnd); this.classList.add("dragging"); } private _stopDragging(): void { this._isDragging = false; document.removeEventListener("mousemove", this._handleMouseMove); document.removeEventListener("mouseup", this._handleMouseUp); document.removeEventListener("touchmove", this._handleTouchMove); document.removeEventListener("touchend", this._handleTouchEnd); this.classList.remove("dragging"); } private _handleMouseMove = (event: MouseEvent): void => { if (!this._isDragging || this.disabled) return; event.preventDefault(); this._updateValueFromPosition(event.clientX, event.clientY); }; private _handleTouchMove = (event: TouchEvent): void => { if (!this._isDragging || this.disabled) return; event.preventDefault(); const touch = event.touches[0]; this._updateValueFromPosition(touch.clientX, touch.clientY); }; private _handleMouseUp = (): void => { this._stopDragging(); }; private _handleTouchEnd = (): void => { this._stopDragging(); }; private _updateValueFromPosition(clientX: number, clientY: number): void { if (!this.trackElement) return; const rect = this.trackElement.getBoundingClientRect(); let percentage: number; if (this.orientation === "horizontal") { const x = clientX - rect.left; percentage = (x / rect.width) * 100; } else { // For vertical sliders, invert the percentage (0% at bottom) const y = clientY - rect.top; percentage = (1 - y / rect.height) * 100; } percentage = Math.max(0, Math.min(100, percentage)); const range = this.max - this.min; const newValue = this.min + (percentage / 100) * range; const snappedValue = this._snapToStep(newValue); if (this.value !== snappedValue) { const oldValue = this.value; this.value = snappedValue; this.emit("ct-input", { value: snappedValue, oldValue }); this.emit("ct-change", { value: snappedValue, oldValue }); } } private _handleKeyDown = (event: KeyboardEvent): void => { if (this.disabled) return; let newValue = this.value; const bigStep = this.step * 10; switch (event.key) { case "ArrowLeft": case "ArrowDown": event.preventDefault(); newValue -= this.step; break; case "ArrowRight": case "ArrowUp": event.preventDefault(); newValue += this.step; break; case "PageDown": event.preventDefault(); newValue -= bigStep; break; case "PageUp": event.preventDefault(); newValue += bigStep; break; case "Home": event.preventDefault(); newValue = this.min; break; case "End": event.preventDefault(); newValue = this.max; break; default: return; } const clampedValue = this._clampValue(newValue); if (clampedValue !== this.value) { const oldValue = this.value; this.value = clampedValue; this.emit("ct-change", { value: clampedValue, oldValue }); } }; /** * Set the slider value programmatically */ setValue(value: number): void { this.value = this._snapToStep(this._clampValue(value)); } /** * Get the current value as a percentage (0-100) */ getPercentageValue(): number { return this._getPercentage(); } /** * Increment the slider value by one step */ increment(): void { this.setValue(this.value + this.step); } /** * Decrement the slider value by one step */ decrement(): void { this.setValue(this.value - this.step); } } globalThis.customElements.define("ct-slider", CTSlider);