@smui/slider
Version:
Svelte Material UI - Slider
1,535 lines (1,372 loc) • 49.4 kB
text/typescript
/**
* @license
* Copyright 2020 Google Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
import { AnimationFrame } from '@smui/common/animation/animationframe';
import { getCorrectPropertyName } from '@smui/common/animation/util';
import { MDCFoundation } from '@smui/common/base/foundation';
import type { SpecificEventListener } from '@smui/common/base/types';
import type { MDCSliderAdapter } from './adapter';
import { attributes, cssClasses, numbers, strings } from './constants';
import { Thumb, TickMark } from './types';
enum AnimationKeys {
SLIDER_UPDATE = 'slider_update',
}
// Accessing `window` without a `typeof` check will throw on Node environments.
const HAS_WINDOW = typeof window !== 'undefined';
/**
* Foundation class for slider. Responsibilities include:
* - Updating slider values (internal state and DOM updates) based on client
* 'x' position.
* - Updating DOM after slider property updates (e.g. min, max).
*/
export class MDCSliderFoundation extends MDCFoundation<MDCSliderAdapter> {
static SUPPORTS_POINTER_EVENTS =
HAS_WINDOW &&
Boolean(window.PointerEvent) &&
// #setPointerCapture is buggy on iOS, so we can't use pointer events
// until the following bug is fixed:
// https://bugs.webkit.org/show_bug.cgi?id=220196
!isIOS();
// Whether the initial styles (to position the thumb, before component
// initialization) have been removed.
private initialStylesRemoved = false;
private min!: number; // Assigned in init()
private max!: number; // Assigned in init()
// If `isRange`, this is the value of Thumb.START. Otherwise, defaults to min.
private valueStart!: number; // Assigned in init()
// If `isRange`, this it the value of Thumb.END. Otherwise, it is the
// value of the single thumb.
private value!: number; // Assigned in init()
private isDisabled = false;
private isDiscrete = false;
private step = numbers.STEP_SIZE;
private minRange = numbers.MIN_RANGE;
// Number of digits after the decimal point to round to, when computing
// values. This is based on the step size by default and is used to
// avoid floating point precision errors in JS.
private numDecimalPlaces!: number; // Assigned in init()
private hasTickMarks = false;
// The following properties are only set for range sliders.
private isRange = false;
// Tracks the thumb being moved across a slider pointer interaction (down,
// move event).
private thumb: Thumb | null = null;
// `clientX` from the most recent down event. Used in subsequent move
// events to determine which thumb to move (in the case of
// overlapping thumbs).
private downEventClientX: number | null = null;
// `valueStart` before the most recent down event. Used in subsequent up
// events to determine whether to fire the `change` event.
private valueStartBeforeDownEvent!: number; // Assigned in init()
// `value` before the most recent down event. Used in subsequent up
// events to determine whether to fire the `change` event.
private valueBeforeDownEvent!: number; // Assigned in init()
// Width of the start thumb knob.
private startThumbKnobWidth = 0;
// Width of the end thumb knob.
private endThumbKnobWidth = 0;
private readonly animFrame: AnimationFrame;
// Assigned in #initialize.
private mousedownOrTouchstartListener!: SpecificEventListener<
'mousedown' | 'touchstart'
>;
// Assigned in #initialize.
private moveListener!: SpecificEventListener<
'pointermove' | 'mousemove' | 'touchmove'
>;
private pointerdownListener!: SpecificEventListener<'pointerdown'>; // Assigned in #initialize.
private pointerupListener!: SpecificEventListener<'pointerup'>; // Assigned in #initialize.
private thumbMouseenterListener!: SpecificEventListener<'mouseenter'>; // Assigned in #initialize.
private thumbMouseleaveListener!: SpecificEventListener<'mouseleave'>; // Assigned in #initialize.
private inputStartChangeListener!: SpecificEventListener<'change'>; // Assigned in #initialize.
private inputEndChangeListener!: SpecificEventListener<'change'>; // Assigned in #initialize.
private inputStartFocusListener!: SpecificEventListener<'focus'>; // Assigned in #initialize.
private inputEndFocusListener!: SpecificEventListener<'focus'>; // Assigned in #initialize.
private inputStartBlurListener!: SpecificEventListener<'blur'>; // Assigned in #initialize.
private inputEndBlurListener!: SpecificEventListener<'blur'>; // Assigned in #initialize.
private resizeListener!: SpecificEventListener<'resize'>; // Assigned in #initialize.
constructor(adapter?: Partial<MDCSliderAdapter>) {
super({ ...MDCSliderFoundation.defaultAdapter, ...adapter });
this.animFrame = new AnimationFrame();
}
static override get defaultAdapter(): MDCSliderAdapter {
// tslint:disable:object-literal-sort-keys Methods should be in the same
// order as the adapter interface.
return {
hasClass: () => false,
addClass: () => undefined,
removeClass: () => undefined,
addThumbClass: () => undefined,
removeThumbClass: () => undefined,
getAttribute: () => null,
getInputValue: () => '',
setInputValue: () => undefined,
getInputAttribute: () => null,
setInputAttribute: () => null,
removeInputAttribute: () => null,
focusInput: () => undefined,
isInputFocused: () => false,
shouldHideFocusStylesForPointerEvents: () => false,
getThumbKnobWidth: () => 0,
getValueIndicatorContainerWidth: () => 0,
getThumbBoundingClientRect: () =>
({ top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0 }) as any,
getBoundingClientRect: () =>
({ top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0 }) as any,
isRTL: () => false,
setThumbStyleProperty: () => undefined,
removeThumbStyleProperty: () => undefined,
setTrackActiveStyleProperty: () => undefined,
removeTrackActiveStyleProperty: () => undefined,
setValueIndicatorText: () => undefined,
getValueToAriaValueTextFn: () => null,
updateTickMarks: () => undefined,
setPointerCapture: () => undefined,
emitChangeEvent: () => undefined,
emitInputEvent: () => undefined,
emitDragStartEvent: () => undefined,
emitDragEndEvent: () => undefined,
registerEventHandler: () => undefined,
deregisterEventHandler: () => undefined,
registerThumbEventHandler: () => undefined,
deregisterThumbEventHandler: () => undefined,
registerInputEventHandler: () => undefined,
deregisterInputEventHandler: () => undefined,
registerBodyEventHandler: () => undefined,
deregisterBodyEventHandler: () => undefined,
registerWindowEventHandler: () => undefined,
deregisterWindowEventHandler: () => undefined,
};
// tslint:enable:object-literal-sort-keys
}
override init() {
this.isDisabled = this.adapter.hasClass(cssClasses.DISABLED);
this.isDiscrete = this.adapter.hasClass(cssClasses.DISCRETE);
this.hasTickMarks = this.adapter.hasClass(cssClasses.TICK_MARKS);
this.isRange = this.adapter.hasClass(cssClasses.RANGE);
const min = this.convertAttributeValueToNumber(
this.adapter.getInputAttribute(
attributes.INPUT_MIN,
this.isRange ? Thumb.START : Thumb.END,
),
attributes.INPUT_MIN,
);
const max = this.convertAttributeValueToNumber(
this.adapter.getInputAttribute(attributes.INPUT_MAX, Thumb.END),
attributes.INPUT_MAX,
);
const value = this.convertAttributeValueToNumber(
this.adapter.getInputAttribute(attributes.INPUT_VALUE, Thumb.END),
attributes.INPUT_VALUE,
);
const valueStart = this.isRange
? this.convertAttributeValueToNumber(
this.adapter.getInputAttribute(attributes.INPUT_VALUE, Thumb.START),
attributes.INPUT_VALUE,
)
: min;
const stepAttr = this.adapter.getInputAttribute(
attributes.INPUT_STEP,
Thumb.END,
);
const step = stepAttr
? this.convertAttributeValueToNumber(stepAttr, attributes.INPUT_STEP)
: this.step;
const minRangeAttr = this.adapter.getAttribute(attributes.DATA_MIN_RANGE);
const minRange = minRangeAttr
? this.convertAttributeValueToNumber(
minRangeAttr,
attributes.DATA_MIN_RANGE,
)
: this.minRange;
this.validateProperties({ min, max, value, valueStart, step, minRange });
this.min = min;
this.max = max;
this.value = value;
this.valueStart = valueStart;
this.step = step;
this.minRange = minRange;
this.numDecimalPlaces = getNumDecimalPlaces(this.step);
this.valueBeforeDownEvent = value;
this.valueStartBeforeDownEvent = valueStart;
this.mousedownOrTouchstartListener =
this.handleMousedownOrTouchstart.bind(this);
this.moveListener = this.handleMove.bind(this);
this.pointerdownListener = this.handlePointerdown.bind(this);
this.pointerupListener = this.handlePointerup.bind(this);
this.thumbMouseenterListener = this.handleThumbMouseenter.bind(this);
this.thumbMouseleaveListener = this.handleThumbMouseleave.bind(this);
this.inputStartChangeListener = () => {
this.handleInputChange(Thumb.START);
};
this.inputEndChangeListener = () => {
this.handleInputChange(Thumb.END);
};
this.inputStartFocusListener = () => {
this.handleInputFocus(Thumb.START);
};
this.inputEndFocusListener = () => {
this.handleInputFocus(Thumb.END);
};
this.inputStartBlurListener = () => {
this.handleInputBlur(Thumb.START);
};
this.inputEndBlurListener = () => {
this.handleInputBlur(Thumb.END);
};
this.resizeListener = this.handleResize.bind(this);
this.registerEventHandlers();
}
override destroy() {
this.deregisterEventHandlers();
}
setMin(value: number) {
this.min = value;
if (!this.isRange) {
this.valueStart = value;
}
this.updateUI();
}
setMax(value: number) {
this.max = value;
this.updateUI();
}
getMin() {
return this.min;
}
getMax() {
return this.max;
}
/**
* - For single point sliders, returns the thumb value.
* - For range (two-thumb) sliders, returns the end thumb's value.
*/
getValue() {
return this.value;
}
/**
* - For single point sliders, sets the thumb value.
* - For range (two-thumb) sliders, sets the end thumb's value.
*/
setValue(value: number) {
if (this.isRange && value < this.valueStart + this.minRange) {
throw new Error(
`end thumb value (${value}) must be >= start thumb ` +
`value (${this.valueStart}) + min range (${this.minRange})`,
);
}
this.updateValue(value, Thumb.END);
}
/**
* Only applicable for range sliders.
* @return The start thumb's value.
*/
getValueStart() {
if (!this.isRange) {
throw new Error('`valueStart` is only applicable for range sliders.');
}
return this.valueStart;
}
/**
* Only applicable for range sliders. Sets the start thumb's value.
*/
setValueStart(valueStart: number) {
if (!this.isRange) {
throw new Error('`valueStart` is only applicable for range sliders.');
}
if (this.isRange && valueStart > this.value - this.minRange) {
throw new Error(
`start thumb value (${valueStart}) must be <= end thumb ` +
`value (${this.value}) - min range (${this.minRange})`,
);
}
this.updateValue(valueStart, Thumb.START);
}
setStep(value: number) {
this.step = value;
this.numDecimalPlaces = getNumDecimalPlaces(value);
this.updateUI();
}
/**
* Only applicable for range sliders. Sets the minimum difference between the
* start and end values.
*/
setMinRange(value: number) {
if (!this.isRange) {
throw new Error('`minRange` is only applicable for range sliders.');
}
if (value < 0) {
throw new Error(
'`minRange` must be non-negative. ' + `Current value: ${value}`,
);
}
if (this.value - this.valueStart < value) {
throw new Error(
`start thumb value (${this.valueStart}) and end thumb value ` +
`(${this.value}) must differ by at least ${value}.`,
);
}
this.minRange = value;
}
setIsDiscrete(value: boolean) {
this.isDiscrete = value;
this.updateValueIndicatorUI();
this.updateTickMarksUI();
}
getStep() {
return this.step;
}
getMinRange() {
if (!this.isRange) {
throw new Error('`minRange` is only applicable for range sliders.');
}
return this.minRange;
}
setHasTickMarks(value: boolean) {
this.hasTickMarks = value;
this.updateTickMarksUI();
}
getDisabled() {
return this.isDisabled;
}
/**
* Sets disabled state, including updating styles and thumb tabindex.
*/
setDisabled(disabled: boolean) {
this.isDisabled = disabled;
if (disabled) {
this.adapter.addClass(cssClasses.DISABLED);
if (this.isRange) {
this.adapter.setInputAttribute(
attributes.INPUT_DISABLED,
'',
Thumb.START,
);
}
this.adapter.setInputAttribute(attributes.INPUT_DISABLED, '', Thumb.END);
} else {
this.adapter.removeClass(cssClasses.DISABLED);
if (this.isRange) {
this.adapter.removeInputAttribute(
attributes.INPUT_DISABLED,
Thumb.START,
);
}
this.adapter.removeInputAttribute(attributes.INPUT_DISABLED, Thumb.END);
}
}
/** @return Whether the slider is a range slider. */
getIsRange() {
return this.isRange;
}
/**
* - Updates UI based on internal state.
*/
layout({ skipUpdateUI }: { skipUpdateUI?: boolean } = {}) {
if (this.isRange) {
this.startThumbKnobWidth = this.adapter.getThumbKnobWidth(Thumb.START);
this.endThumbKnobWidth = this.adapter.getThumbKnobWidth(Thumb.END);
}
if (!skipUpdateUI) {
this.updateUI();
}
}
/** Handles resize events on the window. */
handleResize() {
this.layout();
}
/**
* Handles pointer down events on the slider root element.
*/
handleDown(event: PointerEvent | MouseEvent | TouchEvent) {
if (this.isDisabled) return;
this.valueStartBeforeDownEvent = this.valueStart;
this.valueBeforeDownEvent = this.value;
const clientX =
(event as MouseEvent).clientX != null
? (event as MouseEvent).clientX
: (event as TouchEvent).targetTouches[0].clientX;
this.downEventClientX = clientX;
const value = this.mapClientXOnSliderScale(clientX);
this.thumb = this.getThumbFromDownEvent(clientX, value);
if (this.thumb === null) return;
this.handleDragStart(event, value, this.thumb);
this.updateValue(value, this.thumb, { emitInputEvent: true });
}
/**
* Handles pointer move events on the slider root element.
*/
handleMove(event: PointerEvent | MouseEvent | TouchEvent) {
if (this.isDisabled) return;
// Prevent scrolling.
event.preventDefault();
const clientX =
(event as MouseEvent).clientX != null
? (event as MouseEvent).clientX
: (event as TouchEvent).targetTouches[0].clientX;
const dragAlreadyStarted = this.thumb != null;
this.thumb = this.getThumbFromMoveEvent(clientX);
if (this.thumb === null) return;
const value = this.mapClientXOnSliderScale(clientX);
if (!dragAlreadyStarted) {
this.handleDragStart(event, value, this.thumb);
this.adapter.emitDragStartEvent(value, this.thumb);
}
this.updateValue(value, this.thumb, { emitInputEvent: true });
}
/**
* Handles pointer up events on the slider root element.
*/
handleUp() {
if (this.isDisabled || this.thumb === null) return;
// Remove the focused state and hide the value indicator(s) (if present)
// if focus state is meant to be hidden.
if (this.adapter.shouldHideFocusStylesForPointerEvents?.()) {
this.handleInputBlur(this.thumb);
}
const oldValue =
this.thumb === Thumb.START
? this.valueStartBeforeDownEvent
: this.valueBeforeDownEvent;
const newValue = this.thumb === Thumb.START ? this.valueStart : this.value;
if (oldValue !== newValue) {
this.adapter.emitChangeEvent(newValue, this.thumb);
}
this.adapter.emitDragEndEvent(newValue, this.thumb);
this.thumb = null;
}
/**
* For range, discrete slider, shows the value indicator on both thumbs.
*/
handleThumbMouseenter() {
if (!this.isDiscrete || !this.isRange) return;
this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.START);
this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END);
}
/**
* For range, discrete slider, hides the value indicator on both thumbs.
*/
handleThumbMouseleave() {
if (!this.isDiscrete || !this.isRange) return;
if (
(!this.adapter.shouldHideFocusStylesForPointerEvents?.() &&
(this.adapter.isInputFocused(Thumb.START) ||
this.adapter.isInputFocused(Thumb.END))) ||
this.thumb
) {
// Leave value indicator shown if either input is focused or the thumb is
// being dragged.
return;
}
this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.START);
this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END);
}
handleMousedownOrTouchstart(event: MouseEvent | TouchEvent) {
const moveEventType =
event.type === 'mousedown' ? 'mousemove' : 'touchmove';
// After a down event on the slider root, listen for move events on
// body (so the slider value is updated for events outside of the
// slider root).
this.adapter.registerBodyEventHandler(moveEventType, this.moveListener);
const upHandler = () => {
this.handleUp();
// Once the drag is finished (up event on body), remove the move
// handler.
this.adapter.deregisterBodyEventHandler(moveEventType, this.moveListener);
// Also stop listening for subsequent up events.
this.adapter.deregisterEventHandler('mouseup', upHandler);
this.adapter.deregisterEventHandler('touchend', upHandler);
};
this.adapter.registerBodyEventHandler('mouseup', upHandler);
this.adapter.registerBodyEventHandler('touchend', upHandler);
this.handleDown(event);
}
handlePointerdown(event: PointerEvent) {
const isPrimaryButton = event.button === 0;
if (!isPrimaryButton) return;
if (event.pointerId != null) {
this.adapter.setPointerCapture(event.pointerId);
}
this.adapter.registerEventHandler('pointermove', this.moveListener);
this.handleDown(event);
}
/**
* Handles input `change` event by setting internal slider value to match
* input's new value.
*/
handleInputChange(thumb: Thumb) {
const value = Number(this.adapter.getInputValue(thumb));
if (thumb === Thumb.START) {
this.setValueStart(value);
} else {
this.setValue(value);
}
this.adapter.emitChangeEvent(
thumb === Thumb.START ? this.valueStart : this.value,
thumb,
);
this.adapter.emitInputEvent(
thumb === Thumb.START ? this.valueStart : this.value,
thumb,
);
}
/** Shows activated state and value indicator on thumb(s). */
handleInputFocus(thumb: Thumb) {
this.adapter.addThumbClass(cssClasses.THUMB_FOCUSED, thumb);
if (!this.isDiscrete) return;
this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, thumb);
if (this.isRange) {
const otherThumb = thumb === Thumb.START ? Thumb.END : Thumb.START;
this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, otherThumb);
}
}
/** Removes activated state and value indicator from thumb(s). */
handleInputBlur(thumb: Thumb) {
this.adapter.removeThumbClass(cssClasses.THUMB_FOCUSED, thumb);
if (!this.isDiscrete) return;
this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, thumb);
if (this.isRange) {
const otherThumb = thumb === Thumb.START ? Thumb.END : Thumb.START;
this.adapter.removeThumbClass(
cssClasses.THUMB_WITH_INDICATOR,
otherThumb,
);
}
}
/**
* Emits custom dragStart event, along with focusing the underlying input.
*/
private handleDragStart(
event: PointerEvent | MouseEvent | TouchEvent,
value: number,
thumb: Thumb,
) {
this.adapter.emitDragStartEvent(value, thumb);
this.adapter.focusInput(thumb);
// Restore focused state and show the value indicator(s) (if present)
// in case they were previously hidden on dragEnd.
// This is needed if the input is already focused, in which case
// #focusInput above wouldn't actually trigger #handleInputFocus,
// which is why we need to invoke it manually here.
if (this.adapter.shouldHideFocusStylesForPointerEvents?.()) {
this.handleInputFocus(thumb);
}
// Prevent the input (that we just focused) from losing focus.
event.preventDefault();
}
/**
* @return The thumb to be moved based on initial down event.
*/
private getThumbFromDownEvent(clientX: number, value: number): Thumb | null {
// For single point slider, thumb to be moved is always the END (only)
// thumb.
if (!this.isRange) return Thumb.END;
// Check if event press point is in the bounds of any thumb.
const thumbStartRect = this.adapter.getThumbBoundingClientRect(Thumb.START);
const thumbEndRect = this.adapter.getThumbBoundingClientRect(Thumb.END);
const inThumbStartBounds =
clientX >= thumbStartRect.left && clientX <= thumbStartRect.right;
const inThumbEndBounds =
clientX >= thumbEndRect.left && clientX <= thumbEndRect.right;
if (inThumbStartBounds && inThumbEndBounds) {
// Thumbs overlapping. Thumb to be moved cannot be determined yet.
return null;
}
// If press is in bounds for either thumb on down event, that's the thumb
// to be moved.
if (inThumbStartBounds) {
return Thumb.START;
}
if (inThumbEndBounds) {
return Thumb.END;
}
// For presses outside the range, return whichever thumb is closer.
if (value < this.valueStart) {
return Thumb.START;
}
if (value > this.value) {
return Thumb.END;
}
// For presses inside the range, return whichever thumb is closer.
return value - this.valueStart <= this.value - value
? Thumb.START
: Thumb.END;
}
/**
* @return The thumb to be moved based on move event (based on drag
* direction from original down event). Only applicable if thumbs
* were overlapping in the down event.
*/
private getThumbFromMoveEvent(clientX: number): Thumb | null {
// Thumb has already been chosen.
if (this.thumb !== null) return this.thumb;
if (this.downEventClientX === null) {
throw new Error('`downEventClientX` is null after move event.');
}
const moveDistanceUnderThreshold =
Math.abs(this.downEventClientX - clientX) < numbers.THUMB_UPDATE_MIN_PX;
if (moveDistanceUnderThreshold) return this.thumb;
const draggedThumbToLeft = clientX < this.downEventClientX;
if (draggedThumbToLeft) {
return this.adapter.isRTL() ? Thumb.END : Thumb.START;
} else {
return this.adapter.isRTL() ? Thumb.START : Thumb.END;
}
}
/**
* Updates UI based on internal state.
* @param thumb Thumb whose value is being updated. If undefined, UI is
* updated for both thumbs based on current internal state.
*/
private updateUI(thumb?: Thumb) {
if (thumb) {
this.updateThumbAndInputAttributes(thumb);
} else {
this.updateThumbAndInputAttributes(Thumb.START);
this.updateThumbAndInputAttributes(Thumb.END);
}
this.updateThumbAndTrackUI(thumb);
this.updateValueIndicatorUI(thumb);
this.updateTickMarksUI();
}
/**
* Updates thumb and input attributes based on current value.
* @param thumb Thumb whose aria attributes to update.
*/
private updateThumbAndInputAttributes(thumb?: Thumb) {
if (!thumb) return;
const value =
this.isRange && thumb === Thumb.START ? this.valueStart : this.value;
const valueStr = String(value);
this.adapter.setInputAttribute(attributes.INPUT_VALUE, valueStr, thumb);
if (this.isRange && thumb === Thumb.START) {
this.adapter.setInputAttribute(
attributes.INPUT_MIN,
String(value + this.minRange),
Thumb.END,
);
} else if (this.isRange && thumb === Thumb.END) {
this.adapter.setInputAttribute(
attributes.INPUT_MAX,
String(value - this.minRange),
Thumb.START,
);
}
// Sync attribute with property.
if (this.adapter.getInputValue(thumb) !== valueStr) {
this.adapter.setInputValue(valueStr, thumb);
}
const valueToAriaValueTextFn = this.adapter.getValueToAriaValueTextFn();
if (valueToAriaValueTextFn) {
this.adapter.setInputAttribute(
attributes.ARIA_VALUETEXT,
valueToAriaValueTextFn(value, thumb),
thumb,
);
}
}
/**
* Updates value indicator UI based on current value.
* @param thumb Thumb whose value indicator to update. If undefined, all
* thumbs' value indicators are updated.
*/
private updateValueIndicatorUI(thumb?: Thumb) {
if (!this.isDiscrete) return;
const value =
this.isRange && thumb === Thumb.START ? this.valueStart : this.value;
this.adapter.setValueIndicatorText(
value,
thumb === Thumb.START ? Thumb.START : Thumb.END,
);
if (!thumb && this.isRange) {
this.adapter.setValueIndicatorText(this.valueStart, Thumb.START);
}
}
/**
* Updates tick marks UI within slider, based on current min, max, and step.
*/
private updateTickMarksUI() {
if (!this.isDiscrete || !this.hasTickMarks) return;
const numTickMarksInactiveStart = (this.valueStart - this.min) / this.step;
const numTickMarksActive = (this.value - this.valueStart) / this.step + 1;
const numTickMarksInactiveEnd = (this.max - this.value) / this.step;
const tickMarksInactiveStart = Array.from<TickMark>({
length: numTickMarksInactiveStart,
}).fill(TickMark.INACTIVE);
const tickMarksActive = Array.from<TickMark>({
length: numTickMarksActive,
}).fill(TickMark.ACTIVE);
const tickMarksInactiveEnd = Array.from<TickMark>({
length: numTickMarksInactiveEnd,
}).fill(TickMark.INACTIVE);
this.adapter.updateTickMarks(
tickMarksInactiveStart
.concat(tickMarksActive)
.concat(tickMarksInactiveEnd),
);
}
/** Maps clientX to a value on the slider scale. */
private mapClientXOnSliderScale(clientX: number): number {
const rect = this.adapter.getBoundingClientRect();
const xPos = clientX - rect.left;
let pctComplete = xPos / rect.width;
if (this.adapter.isRTL()) {
pctComplete = 1 - pctComplete;
}
// Fit the percentage complete between the range [min,max]
// by remapping from [0, 1] to [min, min+(max-min)].
const value = this.min + pctComplete * (this.max - this.min);
if (value === this.max || value === this.min) {
return value;
}
return Number(this.quantize(value).toFixed(this.numDecimalPlaces));
}
/** Calculates the quantized value based on step value. */
private quantize(value: number): number {
const numSteps = Math.round((value - this.min) / this.step);
return this.min + numSteps * this.step;
}
/**
* Updates slider value (internal state and UI) based on the given value.
*/
private updateValue(
value: number,
thumb: Thumb,
{ emitInputEvent }: { emitInputEvent?: boolean } = {},
) {
value = this.clampValue(value, thumb);
if (this.isRange && thumb === Thumb.START) {
// Exit early if current value is the same as the new value.
if (this.valueStart === value) return;
this.valueStart = value;
} else {
// Exit early if current value is the same as the new value.
if (this.value === value) return;
this.value = value;
}
this.updateUI(thumb);
if (emitInputEvent) {
this.adapter.emitInputEvent(
thumb === Thumb.START ? this.valueStart : this.value,
thumb,
);
}
}
/**
* Clamps the given value for the given thumb based on slider properties:
* - Restricts value within [min, max].
* - If range slider, clamp start value <= end value - min range, and
* end value >= start value + min range.
*/
private clampValue(value: number, thumb: Thumb): number {
// Clamp value to [min, max] range.
value = Math.min(Math.max(value, this.min), this.max);
const thumbStartMovedPastThumbEnd =
this.isRange &&
thumb === Thumb.START &&
value > this.value - this.minRange;
if (thumbStartMovedPastThumbEnd) {
return this.value - this.minRange;
}
const thumbEndMovedPastThumbStart =
this.isRange &&
thumb === Thumb.END &&
value < this.valueStart + this.minRange;
if (thumbEndMovedPastThumbStart) {
return this.valueStart + this.minRange;
}
return value;
}
/**
* Updates the active track and thumb style properties to reflect current
* value.
*/
private updateThumbAndTrackUI(thumb?: Thumb) {
const { max, min } = this;
const rect = this.adapter.getBoundingClientRect();
const pctComplete = (this.value - this.valueStart) / (max - min);
const rangePx = pctComplete * rect.width;
const isRtl = this.adapter.isRTL();
const transformProp = HAS_WINDOW
? getCorrectPropertyName(window, 'transform')
: 'transform';
if (this.isRange) {
const thumbLeftPos = this.adapter.isRTL()
? ((max - this.value) / (max - min)) * rect.width
: ((this.valueStart - min) / (max - min)) * rect.width;
const thumbRightPos = thumbLeftPos + rangePx;
this.animFrame.request(AnimationKeys.SLIDER_UPDATE, () => {
// Set active track styles, accounting for animation direction by
// setting `transform-origin`.
const trackAnimatesFromRight =
(!isRtl && thumb === Thumb.START) || (isRtl && thumb !== Thumb.START);
if (trackAnimatesFromRight) {
this.adapter.setTrackActiveStyleProperty('transform-origin', 'right');
this.adapter.setTrackActiveStyleProperty('left', 'auto');
this.adapter.setTrackActiveStyleProperty(
'right',
`${rect.width - thumbRightPos}px`,
);
} else {
this.adapter.setTrackActiveStyleProperty('transform-origin', 'left');
this.adapter.setTrackActiveStyleProperty('right', 'auto');
this.adapter.setTrackActiveStyleProperty('left', `${thumbLeftPos}px`);
}
this.adapter.setTrackActiveStyleProperty(
transformProp,
`scaleX(${pctComplete})`,
);
// Set thumb styles.
const thumbStartPos = isRtl ? thumbRightPos : thumbLeftPos;
const thumbEndPos = this.adapter.isRTL() ? thumbLeftPos : thumbRightPos;
if (thumb === Thumb.START || !thumb || !this.initialStylesRemoved) {
this.adapter.setThumbStyleProperty(
transformProp,
`translateX(${thumbStartPos}px)`,
Thumb.START,
);
this.alignValueIndicator(Thumb.START, thumbStartPos);
}
if (thumb === Thumb.END || !thumb || !this.initialStylesRemoved) {
this.adapter.setThumbStyleProperty(
transformProp,
`translateX(${thumbEndPos}px)`,
Thumb.END,
);
this.alignValueIndicator(Thumb.END, thumbEndPos);
}
this.removeInitialStyles(isRtl);
this.updateOverlappingThumbsUI(thumbStartPos, thumbEndPos, thumb);
});
} else {
this.animFrame.request(AnimationKeys.SLIDER_UPDATE, () => {
const thumbStartPos = isRtl ? rect.width - rangePx : rangePx;
this.adapter.setThumbStyleProperty(
transformProp,
`translateX(${thumbStartPos}px)`,
Thumb.END,
);
this.alignValueIndicator(Thumb.END, thumbStartPos);
this.adapter.setTrackActiveStyleProperty(
transformProp,
`scaleX(${pctComplete})`,
);
this.removeInitialStyles(isRtl);
});
}
}
/**
* Shifts the value indicator to either side if it would otherwise stick
* beyond the slider's length while keeping the caret above the knob.
*/
private alignValueIndicator(thumb: Thumb, thumbPos: number) {
if (!this.isDiscrete) return;
const thumbHalfWidth =
this.adapter.getThumbBoundingClientRect(thumb).width / 2;
const containerWidth = this.adapter.getValueIndicatorContainerWidth(thumb);
const sliderWidth = this.adapter.getBoundingClientRect().width;
if (containerWidth / 2 > thumbPos + thumbHalfWidth) {
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_LEFT,
`${thumbHalfWidth}px`,
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_RIGHT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_TRANSFORM,
'translateX(-50%)',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_LEFT,
'0',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_RIGHT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_TRANSFORM,
'none',
thumb,
);
} else if (containerWidth / 2 > sliderWidth - thumbPos + thumbHalfWidth) {
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_LEFT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_RIGHT,
`${thumbHalfWidth}px`,
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_TRANSFORM,
'translateX(50%)',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_LEFT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_RIGHT,
'0',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_TRANSFORM,
'none',
thumb,
);
} else {
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_LEFT,
'50%',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_RIGHT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CARET_TRANSFORM,
'translateX(-50%)',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_LEFT,
'50%',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_RIGHT,
'auto',
thumb,
);
this.adapter.setThumbStyleProperty(
strings.VAR_VALUE_INDICATOR_CONTAINER_TRANSFORM,
'translateX(-50%)',
thumb,
);
}
}
/**
* Removes initial inline styles if not already removed. `left:<...>%`
* inline styles can be added to position the thumb correctly before JS
* initialization. However, they need to be removed before the JS starts
* positioning the thumb. This is because the JS uses
* `transform:translateX(<...>)px` (for performance reasons) to position
* the thumb (which is not possible for initial styles since we need the
* bounding rect measurements).
*/
private removeInitialStyles(isRtl: boolean) {
if (this.initialStylesRemoved) return;
// Remove thumb position properties that were added for initial render.
const position = isRtl ? 'right' : 'left';
this.adapter.removeThumbStyleProperty(position, Thumb.END);
if (this.isRange) {
this.adapter.removeThumbStyleProperty(position, Thumb.START);
}
this.initialStylesRemoved = true;
this.resetTrackAndThumbAnimation();
}
/**
* Resets track/thumb animation to prevent animation when adding
* `transform` styles to thumb initially.
*/
private resetTrackAndThumbAnimation() {
if (!this.isDiscrete) return;
// Set transition properties to default (no animation), so that the
// newly added `transform` styles do not animate thumb/track from
// their default positions.
const transitionProp = HAS_WINDOW
? getCorrectPropertyName(window, 'transition')
: 'transition';
const transitionDefault = 'none 0s ease 0s';
this.adapter.setThumbStyleProperty(
transitionProp,
transitionDefault,
Thumb.END,
);
if (this.isRange) {
this.adapter.setThumbStyleProperty(
transitionProp,
transitionDefault,
Thumb.START,
);
}
this.adapter.setTrackActiveStyleProperty(transitionProp, transitionDefault);
// In the next frame, remove the transition inline styles we just
// added, such that any animations added in the CSS can now take effect.
requestAnimationFrame(() => {
this.adapter.removeThumbStyleProperty(transitionProp, Thumb.END);
this.adapter.removeTrackActiveStyleProperty(transitionProp);
if (this.isRange) {
this.adapter.removeThumbStyleProperty(transitionProp, Thumb.START);
}
});
}
/**
* Adds THUMB_TOP class to active thumb if thumb knobs overlap; otherwise
* removes THUMB_TOP class from both thumbs.
* @param thumb Thumb that is active (being moved).
*/
private updateOverlappingThumbsUI(
thumbStartPos: number,
thumbEndPos: number,
thumb?: Thumb,
) {
let thumbsOverlap = false;
if (this.adapter.isRTL()) {
const startThumbLeftEdge = thumbStartPos - this.startThumbKnobWidth / 2;
const endThumbRightEdge = thumbEndPos + this.endThumbKnobWidth / 2;
thumbsOverlap = endThumbRightEdge >= startThumbLeftEdge;
} else {
const startThumbRightEdge = thumbStartPos + this.startThumbKnobWidth / 2;
const endThumbLeftEdge = thumbEndPos - this.endThumbKnobWidth / 2;
thumbsOverlap = startThumbRightEdge >= endThumbLeftEdge;
}
if (thumbsOverlap) {
this.adapter.addThumbClass(
cssClasses.THUMB_TOP,
// If no thumb was dragged (in the case of initial layout), end
// thumb is on top by default.
thumb || Thumb.END,
);
this.adapter.removeThumbClass(
cssClasses.THUMB_TOP,
thumb === Thumb.START ? Thumb.END : Thumb.START,
);
} else {
this.adapter.removeThumbClass(cssClasses.THUMB_TOP, Thumb.START);
this.adapter.removeThumbClass(cssClasses.THUMB_TOP, Thumb.END);
}
}
/**
* Converts attribute value to a number, e.g. '100' => 100. Throws errors
* for invalid values.
* @param attributeValue Attribute value, e.g. 100.
* @param attributeName Attribute name, e.g. `aria-valuemax`.
*/
private convertAttributeValueToNumber(
attributeValue: string | null,
attributeName: string,
) {
if (attributeValue === null) {
throw new Error(
'MDCSliderFoundation: `' + attributeName + '` must be non-null.',
);
}
const value = Number(attributeValue);
if (isNaN(value)) {
throw new Error(
'MDCSliderFoundation: `' +
attributeName +
'` value is `' +
attributeValue +
'`, but must be a number.',
);
}
return value;
}
/** Checks that the given properties are valid slider values. */
private validateProperties({
min,
max,
value,
valueStart,
step,
minRange,
}: {
min: number;
max: number;
value: number;
valueStart: number;
step: number;
minRange: number;
}) {
if (min >= max) {
throw new Error(
`MDCSliderFoundation: min must be strictly less than max. ` +
`Current: [min: ${min}, max: ${max}]`,
);
}
if (step <= 0) {
throw new Error(
`MDCSliderFoundation: step must be a positive number. ` +
`Current step: ${step}`,
);
}
if (this.isRange) {
if (value < min || value > max || valueStart < min || valueStart > max) {
throw new Error(
`MDCSliderFoundation: values must be in [min, max] range. ` +
`Current values: [start value: ${valueStart}, end value: ` +
`${value}, min: ${min}, max: ${max}]`,
);
}
if (valueStart > value) {
throw new Error(
`MDCSliderFoundation: start value must be <= end value. ` +
`Current values: [start value: ${valueStart}, end value: ${value}]`,
);
}
if (minRange < 0) {
throw new Error(
`MDCSliderFoundation: minimum range must be non-negative. ` +
`Current min range: ${minRange}`,
);
}
if (value - valueStart < minRange) {
throw new Error(
`MDCSliderFoundation: start value and end value must differ by at least ` +
`${minRange}. Current values: [start value: ${valueStart}, ` +
`end value: ${value}]`,
);
}
const numStepsValueStartFromMin = (valueStart - min) / step;
const numStepsValueFromMin = (value - min) / step;
if (
!Number.isInteger(parseFloat(numStepsValueStartFromMin.toFixed(6))) ||
!Number.isInteger(parseFloat(numStepsValueFromMin.toFixed(6)))
) {
throw new Error(
`MDCSliderFoundation: Slider values must be valid based on the ` +
`step value (${step}). Current values: [start value: ` +
`${valueStart}, end value: ${value}, min: ${min}]`,
);
}
} else {
// Single point slider.
if (value < min || value > max) {
throw new Error(
`MDCSliderFoundation: value must be in [min, max] range. ` +
`Current values: [value: ${value}, min: ${min}, max: ${max}]`,
);
}
const numStepsValueFromMin = (value - min) / step;
if (!Number.isInteger(parseFloat(numStepsValueFromMin.toFixed(6)))) {
throw new Error(
`MDCSliderFoundation: Slider value must be valid based on the ` +
`step value (${step}). Current value: ${value}`,
);
}
}
}
private registerEventHandlers() {
this.adapter.registerWindowEventHandler('resize', this.resizeListener);
if (MDCSliderFoundation.SUPPORTS_POINTER_EVENTS) {
// If supported, use pointer events API with #setPointerCapture.
this.adapter.registerEventHandler(
'pointerdown',
this.pointerdownListener,
);
this.adapter.registerEventHandler('pointerup', this.pointerupListener);
} else {
// Otherwise, fall back to mousedown/touchstart events.
this.adapter.registerEventHandler(
'mousedown',
this.mousedownOrTouchstartListener,
);
this.adapter.registerEventHandler(
'touchstart',
this.mousedownOrTouchstartListener,
);
}
if (this.isRange) {
this.adapter.registerThumbEventHandler(
Thumb.START,
'mouseenter',
this.thumbMouseenterListener,
);
this.adapter.registerThumbEventHandler(
Thumb.START,
'mouseleave',
this.thumbMouseleaveListener,
);
this.adapter.registerInputEventHandler(
Thumb.START,
'change',
this.inputStartChangeListener,
);
this.adapter.registerInputEventHandler(
Thumb.START,
'focus',
this.inputStartFocusListener,
);
this.adapter.registerInputEventHandler(
Thumb.START,
'blur',
this.inputStartBlurListener,
);
}
this.adapter.registerThumbEventHandler(
Thumb.END,
'mouseenter',
this.thumbMouseenterListener,
);
this.adapter.registerThumbEventHandler(
Thumb.END,
'mouseleave',
this.thumbMouseleaveListener,
);
this.adapter.registerInputEventHandler(
Thumb.END,
'change',
this.inputEndChangeListener,
);
this.adapter.registerInputEventHandler(
Thumb.END,
'focus',
this.inputEndFocusListener,
);
this.adapter.registerInputEventHandler(
Thumb.END,
'blur',
this.inputEndBlurListener,
);
}
private deregisterEventHandlers() {
this.adapter.deregisterWindowEventHandler('resize', this.resizeListener);
if (MDCSliderFoundation.SUPPORTS_POINTER_EVENTS) {
this.adapter.deregisterEventHandler(
'pointerdown',
this.pointerdownListener,
);
this.adapter.deregisterEventHandler('pointerup', this.pointerupListener);
} else {
this.adapter.deregisterEventHandler(
'mousedown',
this.mousedownOrTouchstartListener,
);
this.adapter.deregisterEventHandler(
'touchstart',
this.mousedownOrTouchstartListener,
);
}
if (this.isRange) {
this.adapter.deregisterThumbEventHandler(
Thumb.START,
'mouseenter',
this.thumbMouseenterListener,
);
this.adapter.deregisterThumbEventHandler(
Thumb.START,
'mouseleave',
this.thumbMouseleaveListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.START,
'change',
this.inputStartChangeListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.START,
'focus',
this.inputStartFocusListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.START,
'blur',
this.inputStartBlurListener,
);
}
this.adapter.deregisterThumbEventHandler(
Thumb.END,
'mouseenter',
this.thumbMouseenterListener,
);
this.adapter.deregisterThumbEventHandler(
Thumb.END,
'mouseleave',
this.thumbMouseleaveListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.END,
'change',
this.inputEndChangeListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.END,
'focus',
this.inputEndFocusListener,
);
this.adapter.deregisterInputEventHandler(
Thumb.END,
'blur',
this.inputEndBlurListener,
);
}
private handlePointerup() {
this.handleUp();
this.adapter.deregisterEventHandler('pointermove', this.moveListener);
}
}
function isIOS() {
// Source:
// https://stackoverflow.com/questions/9038625/detect-if-device-is-ios
return (
[
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
);
}
/**
* Given a number, returns the number of digits that appear after the
* decimal point.
* See
* https://stackoverflow.com/questions/9539513/is-there-a-reliable-way-in-javascript-to-obtain-the-number-of-decimal-places-of
*/
function getNumDecimalPlaces(n: number): number {
// Pull out the fraction and the exponent.
const match = /(?:\.(\d+))?(?:[eE]([+\-]?\d+))?$/.exec(String(n));
// NaN or Infinity or integer.
// We arbitrarily decide that Infinity is integral.
if (!match) return 0;
const fraction = match[1] || ''; // E.g. 1.234e-2 => 234
const exponent = match[2] || 0; // E.g. 1.234e-2 => -2
// Count the number of digits in the fraction and subtract the
// exponent to simulate moving the decimal point left by exponent places.
// 1.234e+2 has 1 fraction digit and '234'.length - 2 == 1
// 1.234e-2 has 5 fraction digit and '234'.length - -2 == 5
return Math.max(
0, // lower limit
(fraction === '0' ? 0 : fraction.length) - Number(exponent),
);
}