UNPKG

@smui/slider

Version:

Svelte Material UI - Slider

1,031 lines 47.7 kB
/** * @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/animation/animationframe'; import { getCorrectPropertyName } from '@smui/animation/util'; import { MDCFoundation } from '@smui/base/foundation'; import { attributes, cssClasses, numbers, strings } from './constants'; import { Thumb, TickMark } from './types'; var AnimationKeys; (function (AnimationKeys) { AnimationKeys["SLIDER_UPDATE"] = "slider_update"; })(AnimationKeys || (AnimationKeys = {})); // 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 { constructor(adapter) { super(Object.assign(Object.assign({}, MDCSliderFoundation.defaultAdapter), adapter)); // Whether the initial styles (to position the thumb, before component // initialization) have been removed. this.initialStylesRemoved = false; this.isDisabled = false; this.isDiscrete = false; this.step = numbers.STEP_SIZE; this.minRange = numbers.MIN_RANGE; this.hasTickMarks = false; // The following properties are only set for range sliders. this.isRange = false; // Tracks the thumb being moved across a slider pointer interaction (down, // move event). this.thumb = 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). this.downEventClientX = null; // Width of the start thumb knob. this.startThumbKnobWidth = 0; // Width of the end thumb knob. this.endThumbKnobWidth = 0; this.animFrame = new AnimationFrame(); } static get defaultAdapter() { // 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 }), getBoundingClientRect: () => ({ top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0 }), 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 } 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(); } destroy() { this.deregisterEventHandlers(); } setMin(value) { this.min = value; if (!this.isRange) { this.valueStart = value; } this.updateUI(); } setMax(value) { 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) { 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) { 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) { 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) { 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) { 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) { this.hasTickMarks = value; this.updateTickMarksUI(); } getDisabled() { return this.isDisabled; } /** * Sets disabled state, including updating styles and thumb tabindex. */ setDisabled(disabled) { 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 } = {}) { 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) { if (this.isDisabled) return; this.valueStartBeforeDownEvent = this.valueStart; this.valueBeforeDownEvent = this.value; const clientX = event.clientX != null ? event.clientX : event.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) { if (this.isDisabled) return; // Prevent scrolling. event.preventDefault(); const clientX = event.clientX != null ? event.clientX : event.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() { var _a, _b; 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 ((_b = (_a = this.adapter).shouldHideFocusStylesForPointerEvents) === null || _b === void 0 ? void 0 : _b.call(_a)) { 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() { var _a, _b; if (!this.isDiscrete || !this.isRange) return; if ((!((_b = (_a = this.adapter).shouldHideFocusStylesForPointerEvents) === null || _b === void 0 ? void 0 : _b.call(_a)) && (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) { 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) { 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) { 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) { 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) { 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. */ handleDragStart(event, value, thumb) { var _a, _b; 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 ((_b = (_a = this.adapter).shouldHideFocusStylesForPointerEvents) === null || _b === void 0 ? void 0 : _b.call(_a)) { 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. */ getThumbFromDownEvent(clientX, value) { // 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. */ getThumbFromMoveEvent(clientX) { // 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. */ updateUI(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. */ updateThumbAndInputAttributes(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. */ updateValueIndicatorUI(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. */ 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({ length: numTickMarksInactiveStart, }).fill(TickMark.INACTIVE); const tickMarksActive = Array.from({ length: numTickMarksActive, }).fill(TickMark.ACTIVE); const tickMarksInactiveEnd = Array.from({ length: numTickMarksInactiveEnd, }).fill(TickMark.INACTIVE); this.adapter.updateTickMarks(tickMarksInactiveStart .concat(tickMarksActive) .concat(tickMarksInactiveEnd)); } /** Maps clientX to a value on the slider scale. */ mapClientXOnSliderScale(clientX) { 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. */ quantize(value) { 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. */ updateValue(value, thumb, { emitInputEvent } = {}) { 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. */ clampValue(value, thumb) { // 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. */ updateThumbAndTrackUI(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. */ alignValueIndicator(thumb, thumbPos) { 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). */ removeInitialStyles(isRtl) { 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. */ 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). */ updateOverlappingThumbsUI(thumbStartPos, thumbEndPos, 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`. */ convertAttributeValueToNumber(attributeValue, attributeName) { 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. */ validateProperties({ min, max, value, valueStart, step, minRange, }) { 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}`); } } } 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); } 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); } handlePointerup() { this.handleUp(); this.adapter.deregisterEventHandler('pointermove', this.moveListener); } } MDCSliderFoundation.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(); 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) { // 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)); } //# sourceMappingURL=foundation.js.map