Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ed400ed
feat: implement slider and range-slider pointer events support
web-padawan Jan 14, 2026
7b45a0a
fix: only handle pointer events on thumb or track parts
web-padawan Jan 15, 2026
345b724
fix: improve logic for part detection and tests
web-padawan Jan 15, 2026
61a87f3
refactor: apply review suggestion
web-padawan Jan 15, 2026
611cff6
test: merge assertions in change events tests
web-padawan Jan 15, 2026
26295d3
refactor: add listeners on the slider, test pointer capture
web-padawan Jan 15, 2026
f69f2ae
refactor: only use __thumbIndex for pointermove events
web-padawan Jan 15, 2026
d68d37f
chore: add missing newline
web-padawan Jan 15, 2026
9b8b175
fix: get thumb index when clicking outside the track
web-padawan Jan 15, 2026
73f962c
decompose __applyValue method
vursen Jan 15, 2026
b3bf96f
refactor: do not focus on mousedown on blank space
web-padawan Jan 15, 2026
de12d88
fix: only fire change event on track pointerup
web-padawan Jan 15, 2026
a7ddf5f
Update packages/slider/test/slider.test.ts
web-padawan Jan 15, 2026
b78feee
refactor: move mousedown listener to the mixin
web-padawan Jan 15, 2026
8e2cdb4
fix: always use first or second thumb at the min or max
web-padawan Jan 15, 2026
24bb469
fix: make value update logic work properly
web-padawan Jan 15, 2026
fff6a64
fix: handle negative min value correctly
web-padawan Jan 16, 2026
c023198
fix: ignore pointerdown when right mouse button pressed
web-padawan Jan 16, 2026
ac5b5f1
chore: fix JSDoc comments
web-padawan Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 69 additions & 15 deletions packages/slider/src/vaadin-range-slider.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,6 @@ class RangeSlider extends SliderMixin(
this.__value = [...this.value];
this.__inputId0 = `slider-${generateUniqueId()}`;
this.__inputId1 = `slider-${generateUniqueId()}`;

this.addEventListener('mousedown', (e) => this._onMouseDown(e));
}

/** @protected */
Expand Down Expand Up @@ -163,9 +161,9 @@ class RangeSlider extends SliderMixin(
super.updated(props);

if (props.has('value') || props.has('min') || props.has('max')) {
const value = Array.isArray(this.value) ? this.value : [];
value.forEach((value, idx) => {
this.__updateValue(value, idx);
const value = [...this.value];
value.forEach((v, idx) => {
this.__updateValue(v, idx, value);
});
}
}
Expand Down Expand Up @@ -200,24 +198,81 @@ class RangeSlider extends SliderMixin(

/**
* @param {PointerEvent} event
* @protected
* @private
*/
_onMouseDown(event) {
// Prevent blur if already focused
event.preventDefault();

// Focus the input to allow modifying value using keyboard
this.focus({ focusVisible: false });
__focusInput(event) {
const index = this.__getThumbIndex(event);
this._inputElements[index].focus();
}

/** @private */
__commitValue() {
this.value = [...this.__value];
}

/**
* @param {Event} event
* @return {number}
*/
__getThumbIndex(event) {
if (event.type === 'input') {
return this._inputElements.indexOf(event.target);
}

return this.__getClosestThumb(event);
}

/**
* @param {PointerEvent} event
* @return {number}
* @private
*/
__getClosestThumb(event) {
let closestThumb;

// If both thumbs are at the start, use the second thumb,
// and if both are at tne end, use the first one instead.
if (this.__value[0] === this.__value[1]) {
const { min, max } = this.__getConstraints();
if (this.__value[0] === min) {
return 1;
}

if (this.__value[0] === max) {
return 0;
}
}

const percent = this.__getEventPercent(event);
const value = this.__getValueFromPercent(percent);

// First thumb position from the "end"
const index = this.__value.findIndex((v) => value - v < 0);

// Pick the first one
if (index === 0) {
closestThumb = index;
} else if (index === -1) {
// Pick the last one (position is past all the thumbs)
closestThumb = this.__value.length - 1;
} else {
const lastStart = this.__value[index - 1];
const firstEnd = this.__value[index];
// Pick the first one from the "start" unless thumbs are stacked on top of each other
if (Math.abs(lastStart - value) < Math.abs(firstEnd - value)) {
closestThumb = index - 1;
} else {
// Pick the last one from the "end"
closestThumb = index;
}
}

return closestThumb;
}

/** @private */
__onKeyDown(event) {
this.__thumbIndex = this._inputElements.indexOf(event.target);
const index = this._inputElements.indexOf(event.target);

const prevKeys = ['ArrowLeft', 'ArrowDown'];
const nextKeys = ['ArrowRight', 'ArrowUp'];
Expand All @@ -226,8 +281,7 @@ class RangeSlider extends SliderMixin(
// to prevent the case where slotted range inputs would end up in broken state.
if (
this.__value[0] === this.__value[1] &&
((this.__thumbIndex === 0 && nextKeys.includes(event.key)) ||
(this.__thumbIndex === 1 && prevKeys.includes(event.key)))
((index === 0 && nextKeys.includes(event.key)) || (index === 1 && prevKeys.includes(event.key)))
) {
event.preventDefault();
}
Expand Down
166 changes: 153 additions & 13 deletions packages/slider/src/vaadin-slider-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,33 +46,56 @@ export const SliderMixin = (superClass) =>
constructor() {
super();

this.__thumbIndex = 0;
this.__onPointerMove = this.__onPointerMove.bind(this);
this.__onPointerUp = this.__onPointerUp.bind(this);

// Use separate mousedown listener for focusing the input, as
// pointerdown fires too early and the global `keyboardActive`
// flag isn't updated yet, which incorrectly shows focus-ring
this.addEventListener('mousedown', (e) => this.__onMouseDown(e));
this.addEventListener('pointerdown', (e) => this.__onPointerDown(e));
}

/** @protected */
firstUpdated() {
super.firstUpdated();

this.__lastCommittedValue = this.value;
}

/**
* @param {Event} event
* @return {number}
*/
__getThumbIndex(_event) {
return 0;
}

/**
* @param {number} value
* @param {number} index
* @param {number[]} fullValue
* @private
*/
__updateValue(value, index = this.__thumbIndex) {
__updateValue(value, index, fullValue = this.__value) {
const { min, max, step } = this.__getConstraints();

const minValue = this.__value[index - 1] || min;
const maxValue = this.__value[index + 1] || max;
const minValue = fullValue[index - 1] !== undefined ? fullValue[index - 1] : min;
const maxValue = fullValue[index + 1] !== undefined ? fullValue[index + 1] : max;

const safeValue = Math.min(Math.max(value, minValue), maxValue);

const offset = safeValue - min;
const offset = safeValue - minValue;
const nearestOffset = Math.round(offset / step) * step;
const nearestValue = min + nearestOffset;
const nearestValue = minValue + nearestOffset;

const newValue = Math.round(nearestValue);

this.__value = this.__value.with(index, newValue);
this.__value = fullValue.with(index, newValue);
}

/**
* @return {{ min: number, max: number}}
* @return {{ min: number, max: number, step: number}}
* @private
*/
__getConstraints() {
Expand All @@ -93,24 +116,141 @@ export const SliderMixin = (superClass) =>
return (100 * (value - min)) / (max - min);
}

/**
* @param {number} percent
* @return {number}
* @private
*/
__getValueFromPercent(percent) {
const { min, max } = this.__getConstraints();
return min + percent * (max - min);
}

/**
* @param {PointerEvent} event
* @return {number}
* @private
*/
__getEventPercent(event) {
const offset = event.offsetX;
const size = this.offsetWidth;
const safeOffset = Math.min(Math.max(offset, 0), size);
return safeOffset / size;
}

/**
* @param {PointerEvent} event
* @return {number}
* @private
*/
__getEventValue(event) {
const percent = this.__getEventPercent(event);
return this.__getValueFromPercent(percent);
}

/**
* @param {PointerEvent} event
* @private
*/
__onMouseDown(event) {
const part = event.composedPath()[0].getAttribute('part');
if (!part || (!part.startsWith('track') && !part.startsWith('thumb'))) {
return;
}

// Prevent losing focus
event.preventDefault();

this.__focusInput(event);
}

/**
* @param {PointerEvent} event
* @private
*/
__onPointerDown(event) {
if (event.button !== 0) {
return;
}

const part = event.composedPath()[0].getAttribute('part');
if (!part || (!part.startsWith('track') && !part.startsWith('thumb'))) {
return;
}

this.setPointerCapture(event.pointerId);
this.addEventListener('pointermove', this.__onPointerMove);
this.addEventListener('pointerup', this.__onPointerUp);
this.addEventListener('pointercancel', this.__onPointerUp);

this.__thumbIndex = this.__getThumbIndex(event);

// Update value on track click
if (part.startsWith('track')) {
const newValue = this.__getEventValue(event);
this.__updateValue(newValue, this.__thumbIndex);
this.__commitValue();
}
}

/**
* @param {PointerEvent} event
* @private
*/
__onPointerMove(event) {
const newValue = this.__getEventValue(event);
this.__updateValue(newValue, this.__thumbIndex);
this.__commitValue();
}

/**
* @param {PointerEvent} event
* @private
*/
__onPointerUp(event) {
this.__thumbIndex = null;

this.releasePointerCapture(event.pointerId);
this.removeEventListener('pointermove', this.__onPointerMove);
this.removeEventListener('pointerup', this.__onPointerUp);
this.removeEventListener('pointercancel', this.__onPointerUp);

this.__detectAndDispatchChange();
}

/**
* @param {Event} event
* @private
*/
__focusInput(_event) {
this.focus({ focusVisible: false });
}

/** @private */
__detectAndDispatchChange() {
if (this.__lastCommittedValue !== this.value) {
if (JSON.stringify(this.__lastCommittedValue) !== JSON.stringify(this.value)) {
this.__lastCommittedValue = this.value;
this.dispatchEvent(new Event('change', { bubbles: true }));
}
}

/** @private */
/**
* @param {Event} event
* @private
*/
__onInput(event) {
this.__updateValue(event.target.value);
const index = this.__getThumbIndex(event);
this.__updateValue(event.target.value, index);
this.__commitValue();
this.__detectAndDispatchChange();
}

/** @private */
/**
* @param {Event} event
* @private
*/
__onChange(event) {
event.stopPropagation();
this.__detectAndDispatchChange();
}

/**
Expand Down
14 changes: 0 additions & 14 deletions packages/slider/src/vaadin-slider.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,6 @@ class Slider extends SliderMixin(

this.__value = [this.value];
this.__inputId = `slider-${generateUniqueId()}`;

this.addEventListener('mousedown', (e) => this._onMouseDown(e));
}

/** @protected */
Expand Down Expand Up @@ -167,18 +165,6 @@ class Slider extends SliderMixin(
__commitValue() {
this.value = this.__value[0];
}

/**
* @param {PointerEvent} event
* @protected
*/
_onMouseDown(event) {
// Prevent blur if already focused
event.preventDefault();

// Focus the input to allow modifying value using keyboard
this.focus({ focusVisible: false });
}
}

defineCustomElement(Slider);
Expand Down
Loading