/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { booleanAttribute, ChangeDetectorRef, Directive, ElementRef, EventEmitter, forwardRef, inject, Input, NgZone, numberAttribute, OnDestroy, Output, signal, } from '@angular/core'; import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms'; import {Subject} from 'rxjs'; import { _MatThumb, MatSliderDragEvent, _MatSlider, _MatSliderRangeThumb, _MatSliderThumb, MAT_SLIDER_RANGE_THUMB, MAT_SLIDER_THUMB, MAT_SLIDER, } from './slider-interface'; import {Platform} from '@angular/cdk/platform'; /** * Provider that allows the slider thumb to register as a ControlValueAccessor. * @docs-private */ export const MAT_SLIDER_THUMB_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatSliderThumb), multi: true, }; /** * Provider that allows the range slider thumb to register as a ControlValueAccessor. * @docs-private */ export const MAT_SLIDER_RANGE_THUMB_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MatSliderRangeThumb), multi: true, }; /** * Directive that adds slider-specific behaviors to an input element inside ``. * Up to two may be placed inside of a ``. * * If one is used, the selector `matSliderThumb` must be used, and the outcome will be a normal * slider. If two are used, the selectors `matSliderStartThumb` and `matSliderEndThumb` must be * used, and the outcome will be a range slider with two slider thumbs. */ @Directive({ selector: 'input[matSliderThumb]', exportAs: 'matSliderThumb', host: { 'class': 'mdc-slider__input', 'type': 'range', '[attr.aria-valuetext]': '_valuetext()', '(change)': '_onChange()', '(input)': '_onInput()', // TODO(wagnermaciel): Consider using a global event listener instead. // Reason: I have found a semi-consistent way to mouse up without triggering this event. '(blur)': '_onBlur()', '(focus)': '_onFocus()', }, providers: [ MAT_SLIDER_THUMB_VALUE_ACCESSOR, {provide: MAT_SLIDER_THUMB, useExisting: MatSliderThumb}, ], }) export class MatSliderThumb implements _MatSliderThumb, OnDestroy, ControlValueAccessor { readonly _ngZone = inject(NgZone); readonly _elementRef = inject>(ElementRef); readonly _cdr = inject(ChangeDetectorRef); protected _slider = inject<_MatSlider>(MAT_SLIDER); @Input({transform: numberAttribute}) get value(): number { return numberAttribute(this._hostElement.value, 0); } set value(value: number) { value = isNaN(value) ? 0 : value; const stringValue = value + ''; if (!this._hasSetInitialValue) { this._initialValue = stringValue; return; } if (this._isActive) { return; } this._setValue(stringValue); } /** * Handles programmatic value setting. This has been split out to * allow the range thumb to override it and add additional necessary logic. */ protected _setValue(value: string) { this._hostElement.value = value; this._updateThumbUIByValue(); this._slider._onValueChange(this); this._cdr.detectChanges(); this._slider._cdr.markForCheck(); } /** Event emitted when the `value` is changed. */ @Output() readonly valueChange: EventEmitter = new EventEmitter(); /** Event emitted when the slider thumb starts being dragged. */ @Output() readonly dragStart: EventEmitter = new EventEmitter(); /** Event emitted when the slider thumb stops being dragged. */ @Output() readonly dragEnd: EventEmitter = new EventEmitter(); /** * The current translateX in px of the slider visual thumb. * @docs-private */ get translateX(): number { if (this._slider.min >= this._slider.max) { this._translateX = this._tickMarkOffset; return this._translateX; } if (this._translateX === undefined) { this._translateX = this._calcTranslateXByValue(); } return this._translateX; } set translateX(v: number) { this._translateX = v; } private _translateX: number | undefined; /** * Indicates whether this thumb is the start or end thumb. * @docs-private */ thumbPosition: _MatThumb = _MatThumb.END; /** @docs-private */ get min(): number { return numberAttribute(this._hostElement.min, 0); } set min(v: number) { this._hostElement.min = v + ''; this._cdr.detectChanges(); } /** @docs-private */ get max(): number { return numberAttribute(this._hostElement.max, 0); } set max(v: number) { this._hostElement.max = v + ''; this._cdr.detectChanges(); } get step(): number { return numberAttribute(this._hostElement.step, 0); } set step(v: number) { this._hostElement.step = v + ''; this._cdr.detectChanges(); } /** @docs-private */ get disabled(): boolean { return booleanAttribute(this._hostElement.disabled); } set disabled(v: boolean) { this._hostElement.disabled = v; this._cdr.detectChanges(); if (this._slider.disabled !== this.disabled) { this._slider.disabled = this.disabled; } } /** The percentage of the slider that coincides with the value. */ get percentage(): number { if (this._slider.min >= this._slider.max) { return this._slider._isRtl ? 1 : 0; } return (this.value - this._slider.min) / (this._slider.max - this._slider.min); } /** @docs-private */ get fillPercentage(): number { if (!this._slider._cachedWidth) { return this._slider._isRtl ? 1 : 0; } if (this._translateX === 0) { return 0; } return this.translateX / this._slider._cachedWidth; } /** The host native HTML input element. */ _hostElement = this._elementRef.nativeElement; /** The aria-valuetext string representation of the input's value. */ _valuetext = signal(''); /** The radius of a native html slider's knob. */ _knobRadius: number = 8; /** The distance in px from the start of the slider track to the first tick mark. */ _tickMarkOffset = 3; /** Whether user's cursor is currently in a mouse down state on the input. */ _isActive: boolean = false; /** Whether the input is currently focused (either by tab or after clicking). */ _isFocused: boolean = false; /** Used to relay updates to _isFocused to the slider visual thumbs. */ private _setIsFocused(v: boolean): void { this._isFocused = v; } /** * Whether the initial value has been set. * This exists because the initial value cannot be immediately set because the min and max * must first be relayed from the parent MatSlider component, which can only happen later * in the component lifecycle. */ private _hasSetInitialValue: boolean = false; /** The stored initial value. */ _initialValue: string | undefined; /** Defined when a user is using a form control to manage slider value & validation. */ private _formControl: FormControl | undefined; /** Emits when the component is destroyed. */ protected readonly _destroyed = new Subject(); /** * Indicates whether UI updates should be skipped. * * This flag is used to avoid flickering * when correcting values on pointer up/down. */ _skipUIUpdate: boolean = false; /** Callback called when the slider input value changes. */ protected _onChangeFn: ((value: any) => void) | undefined; /** Callback called when the slider input has been touched. */ private _onTouchedFn: () => void = () => {}; /** * Whether the NgModel has been initialized. * * This flag is used to ignore ghost null calls to * writeValue which can break slider initialization. * * See https://github.com/angular/angular/issues/14988. */ protected _isControlInitialized = false; private _platform = inject(Platform); constructor(...args: unknown[]); constructor() { this._ngZone.runOutsideAngular(() => { this._hostElement.addEventListener('pointerdown', this._onPointerDown.bind(this)); this._hostElement.addEventListener('pointermove', this._onPointerMove.bind(this)); this._hostElement.addEventListener('pointerup', this._onPointerUp.bind(this)); }); } ngOnDestroy(): void { this._hostElement.removeEventListener('pointerdown', this._onPointerDown); this._hostElement.removeEventListener('pointermove', this._onPointerMove); this._hostElement.removeEventListener('pointerup', this._onPointerUp); this._destroyed.next(); this._destroyed.complete(); this.dragStart.complete(); this.dragEnd.complete(); } /** @docs-private */ initProps(): void { this._updateWidthInactive(); // If this or the parent slider is disabled, just make everything disabled. if (this.disabled !== this._slider.disabled) { // The MatSlider setter for disabled will relay this and disable both inputs. this._slider.disabled = true; } this.step = this._slider.step; this.min = this._slider.min; this.max = this._slider.max; this._initValue(); } /** @docs-private */ initUI(): void { this._updateThumbUIByValue(); } _initValue(): void { this._hasSetInitialValue = true; if (this._initialValue === undefined) { this.value = this._getDefaultValue(); } else { this._hostElement.value = this._initialValue; this._updateThumbUIByValue(); this._slider._onValueChange(this); this._cdr.detectChanges(); } } _getDefaultValue(): number { return this.min; } _onBlur(): void { this._setIsFocused(false); this._onTouchedFn(); } _onFocus(): void { this._slider._setTransition(false); this._slider._updateTrackUI(this); this._setIsFocused(true); } _onChange(): void { this.valueChange.emit(this.value); // only used to handle the edge case where user // mousedown on the slider then uses arrow keys. if (this._isActive) { this._updateThumbUIByValue({withAnimation: true}); } } _onInput(): void { this._onChangeFn?.(this.value); // handles arrowing and updating the value when // a step is defined. if (this._slider.step || !this._isActive) { this._updateThumbUIByValue({withAnimation: true}); } this._slider._onValueChange(this); } _onNgControlValueChange(): void { // only used to handle when the value change // originates outside of the slider. if (!this._isActive || !this._isFocused) { this._slider._onValueChange(this); this._updateThumbUIByValue(); } this._slider.disabled = this._formControl!.disabled; } _onPointerDown(event: PointerEvent): void { if (this.disabled || event.button !== 0) { return; } // On IOS, dragging only works if the pointer down happens on the // slider thumb and the slider does not receive focus from pointer events. if (this._platform.IOS) { const isCursorOnSliderThumb = this._slider._isCursorOnSliderThumb( event, this._slider._getThumb(this.thumbPosition)._hostElement.getBoundingClientRect(), ); this._isActive = isCursorOnSliderThumb; this._updateWidthActive(); this._slider._updateDimensions(); return; } this._isActive = true; this._setIsFocused(true); this._updateWidthActive(); this._slider._updateDimensions(); // Does nothing if a step is defined because we // want the value to snap to the values on input. if (!this._slider.step) { this._updateThumbUIByPointerEvent(event, {withAnimation: true}); } if (!this.disabled) { this._handleValueCorrection(event); this.dragStart.emit({source: this, parent: this._slider, value: this.value}); } } /** * Corrects the value of the slider on pointer up/down. * * Called on pointer down and up because the value is set based * on the inactive width instead of the active width. */ private _handleValueCorrection(event: PointerEvent): void { // Don't update the UI with the current value! The value on pointerdown // and pointerup is calculated in the split second before the input(s) // resize. See _updateWidthInactive() and _updateWidthActive() for more // details. this._skipUIUpdate = true; // Note that this function gets triggered before the actual value of the // slider is updated. This means if we were to set the value here, it // would immediately be overwritten. Using setTimeout ensures the setting // of the value happens after the value has been updated by the // pointerdown event. setTimeout(() => { this._skipUIUpdate = false; this._fixValue(event); }, 0); } /** Corrects the value of the slider based on the pointer event's position. */ _fixValue(event: PointerEvent): void { const xPos = event.clientX - this._slider._cachedLeft; const width = this._slider._cachedWidth; const step = this._slider.step === 0 ? 1 : this._slider.step; const numSteps = Math.floor((this._slider.max - this._slider.min) / step); const percentage = this._slider._isRtl ? 1 - xPos / width : xPos / width; // To ensure the percentage is rounded to the necessary number of decimals. const fixedPercentage = Math.round(percentage * numSteps) / numSteps; const impreciseValue = fixedPercentage * (this._slider.max - this._slider.min) + this._slider.min; const value = Math.round(impreciseValue / step) * step; const prevValue = this.value; if (value === prevValue) { // Because we prevented UI updates, if it turns out that the race // condition didn't happen and the value is already correct, we // have to apply the ui updates now. this._slider._onValueChange(this); this._slider.step > 0 ? this._updateThumbUIByValue() : this._updateThumbUIByPointerEvent(event, {withAnimation: this._slider._hasAnimation}); return; } this.value = value; this.valueChange.emit(this.value); this._onChangeFn?.(this.value); this._slider._onValueChange(this); this._slider.step > 0 ? this._updateThumbUIByValue() : this._updateThumbUIByPointerEvent(event, {withAnimation: this._slider._hasAnimation}); } _onPointerMove(event: PointerEvent): void { // Again, does nothing if a step is defined because // we want the value to snap to the values on input. if (!this._slider.step && this._isActive) { this._updateThumbUIByPointerEvent(event); } } _onPointerUp(): void { if (this._isActive) { this._isActive = false; if (this._platform.SAFARI) { this._setIsFocused(false); } this.dragEnd.emit({source: this, parent: this._slider, value: this.value}); // This setTimeout is to prevent the pointerup from triggering a value // change on the input based on the inactive width. It's not clear why // but for some reason on IOS this race condition is even more common so // the timeout needs to be increased. setTimeout(() => this._updateWidthInactive(), this._platform.IOS ? 10 : 0); } } _clamp(v: number): number { const min = this._tickMarkOffset; const max = this._slider._cachedWidth - this._tickMarkOffset; return Math.max(Math.min(v, max), min); } _calcTranslateXByValue(): number { if (this._slider._isRtl) { return ( (1 - this.percentage) * (this._slider._cachedWidth - this._tickMarkOffset * 2) + this._tickMarkOffset ); } return ( this.percentage * (this._slider._cachedWidth - this._tickMarkOffset * 2) + this._tickMarkOffset ); } _calcTranslateXByPointerEvent(event: PointerEvent): number { return event.clientX - this._slider._cachedLeft; } /** * Used to set the slider width to the correct * dimensions while the user is dragging. */ _updateWidthActive(): void {} /** * Sets the slider input to disproportionate dimensions to allow for touch * events to be captured on touch devices. */ _updateWidthInactive(): void { this._hostElement.style.padding = `0 ${this._slider._inputPadding}px`; this._hostElement.style.width = `calc(100% + ${ this._slider._inputPadding - this._tickMarkOffset * 2 }px)`; this._hostElement.style.left = `-${this._slider._rippleRadius - this._tickMarkOffset}px`; } _updateThumbUIByValue(options?: {withAnimation: boolean}): void { this.translateX = this._clamp(this._calcTranslateXByValue()); this._updateThumbUI(options); } _updateThumbUIByPointerEvent(event: PointerEvent, options?: {withAnimation: boolean}): void { this.translateX = this._clamp(this._calcTranslateXByPointerEvent(event)); this._updateThumbUI(options); } _updateThumbUI(options?: {withAnimation: boolean}) { this._slider._setTransition(!!options?.withAnimation); this._slider._onTranslateXChange(this); } /** * Sets the input's value. * @param value The new value of the input * @docs-private */ writeValue(value: any): void { if (this._isControlInitialized || value !== null) { this.value = value; } } /** * Registers a callback to be invoked when the input's value changes from user input. * @param fn The callback to register * @docs-private */ registerOnChange(fn: any): void { this._onChangeFn = fn; this._isControlInitialized = true; } /** * Registers a callback to be invoked when the input is blurred by the user. * @param fn The callback to register * @docs-private */ registerOnTouched(fn: any): void { this._onTouchedFn = fn; } /** * Sets the disabled state of the slider. * @param isDisabled The new disabled state * @docs-private */ setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } focus(): void { this._hostElement.focus(); } blur(): void { this._hostElement.blur(); } } @Directive({ selector: 'input[matSliderStartThumb], input[matSliderEndThumb]', exportAs: 'matSliderRangeThumb', providers: [ MAT_SLIDER_RANGE_THUMB_VALUE_ACCESSOR, {provide: MAT_SLIDER_RANGE_THUMB, useExisting: MatSliderRangeThumb}, ], }) export class MatSliderRangeThumb extends MatSliderThumb implements _MatSliderRangeThumb { override readonly _cdr = inject(ChangeDetectorRef); /** @docs-private */ getSibling(): _MatSliderRangeThumb | undefined { if (!this._sibling) { this._sibling = this._slider._getInput(this._isEndThumb ? _MatThumb.START : _MatThumb.END) as | MatSliderRangeThumb | undefined; } return this._sibling; } private _sibling: MatSliderRangeThumb | undefined; /** * Returns the minimum translateX position allowed for this slider input's visual thumb. * @docs-private */ getMinPos(): number { const sibling = this.getSibling(); if (!this._isLeftThumb && sibling) { return sibling.translateX; } return this._tickMarkOffset; } /** * Returns the maximum translateX position allowed for this slider input's visual thumb. * @docs-private */ getMaxPos(): number { const sibling = this.getSibling(); if (this._isLeftThumb && sibling) { return sibling.translateX; } return this._slider._cachedWidth - this._tickMarkOffset; } _setIsLeftThumb(): void { this._isLeftThumb = (this._isEndThumb && this._slider._isRtl) || (!this._isEndThumb && !this._slider._isRtl); } /** Whether this slider corresponds to the input on the left hand side. */ _isLeftThumb: boolean; /** Whether this slider corresponds to the input with greater value. */ _isEndThumb: boolean; constructor(...args: unknown[]); constructor() { super(); this._isEndThumb = this._hostElement.hasAttribute('matSliderEndThumb'); this._setIsLeftThumb(); this.thumbPosition = this._isEndThumb ? _MatThumb.END : _MatThumb.START; } override _getDefaultValue(): number { return this._isEndThumb && this._slider._isRange ? this.max : this.min; } override _onInput(): void { super._onInput(); this._updateSibling(); if (!this._isActive) { this._updateWidthInactive(); } } override _onNgControlValueChange(): void { super._onNgControlValueChange(); this.getSibling()?._updateMinMax(); } override _onPointerDown(event: PointerEvent): void { if (this.disabled || event.button !== 0) { return; } if (this._sibling) { this._sibling._updateWidthActive(); this._sibling._hostElement.classList.add('mat-mdc-slider-input-no-pointer-events'); } super._onPointerDown(event); } override _onPointerUp(): void { super._onPointerUp(); if (this._sibling) { setTimeout(() => { this._sibling!._updateWidthInactive(); this._sibling!._hostElement.classList.remove('mat-mdc-slider-input-no-pointer-events'); }); } } override _onPointerMove(event: PointerEvent): void { super._onPointerMove(event); if (!this._slider.step && this._isActive) { this._updateSibling(); } } override _fixValue(event: PointerEvent): void { super._fixValue(event); this._sibling?._updateMinMax(); } override _clamp(v: number): number { return Math.max(Math.min(v, this.getMaxPos()), this.getMinPos()); } _updateMinMax(): void { const sibling = this.getSibling(); if (!sibling) { return; } if (this._isEndThumb) { this.min = Math.max(this._slider.min, sibling.value); this.max = this._slider.max; } else { this.min = this._slider.min; this.max = Math.min(this._slider.max, sibling.value); } } override _updateWidthActive(): void { const minWidth = this._slider._rippleRadius * 2 - this._slider._inputPadding * 2; const maxWidth = this._slider._cachedWidth + this._slider._inputPadding - minWidth - this._tickMarkOffset * 2; const percentage = this._slider.min < this._slider.max ? (this.max - this.min) / (this._slider.max - this._slider.min) : 1; const width = maxWidth * percentage + minWidth; this._hostElement.style.width = `${width}px`; this._hostElement.style.padding = `0 ${this._slider._inputPadding}px`; } override _updateWidthInactive(): void { const sibling = this.getSibling(); if (!sibling) { return; } const maxWidth = this._slider._cachedWidth - this._tickMarkOffset * 2; const midValue = this._isEndThumb ? this.value - (this.value - sibling.value) / 2 : this.value + (sibling.value - this.value) / 2; const _percentage = this._isEndThumb ? (this.max - midValue) / (this._slider.max - this._slider.min) : (midValue - this.min) / (this._slider.max - this._slider.min); const percentage = this._slider.min < this._slider.max ? _percentage : 1; // Extend the native input width by the radius of the ripple let ripplePadding = this._slider._rippleRadius; // If one of the inputs is maximally sized (the value of both thumbs is // equal to the min or max), make that input take up all of the width and // make the other unselectable. if (percentage === 1) { ripplePadding = 48; } else if (percentage === 0) { ripplePadding = 0; } const width = maxWidth * percentage + ripplePadding; this._hostElement.style.width = `${width}px`; this._hostElement.style.padding = '0px'; if (this._isLeftThumb) { this._hostElement.style.left = `-${this._slider._rippleRadius - this._tickMarkOffset}px`; this._hostElement.style.right = 'auto'; } else { this._hostElement.style.left = 'auto'; this._hostElement.style.right = `-${this._slider._rippleRadius - this._tickMarkOffset}px`; } } _updateStaticStyles(): void { this._hostElement.classList.toggle('mat-slider__right-input', !this._isLeftThumb); } private _updateSibling(): void { const sibling = this.getSibling(); if (!sibling) { return; } sibling._updateMinMax(); if (this._isActive) { sibling._updateWidthActive(); } else { sibling._updateWidthInactive(); } } /** * Sets the input's value. * @param value The new value of the input * @docs-private */ override writeValue(value: any): void { if (this._isControlInitialized || value !== null) { this.value = value; this._updateWidthInactive(); this._updateSibling(); } } override _setValue(value: string) { super._setValue(value); this._updateWidthInactive(); this._updateSibling(); } }