sass-references/angular-material/material/slider/slider-input.ts

826 lines
24 KiB
TypeScript

/**
* @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 `<mat-slider>`.
* Up to two may be placed inside of a `<mat-slider>`.
*
* 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<HTMLInputElement>>(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<number> = new EventEmitter<number>();
/** Event emitted when the slider thumb starts being dragged. */
@Output() readonly dragStart: EventEmitter<MatSliderDragEvent> =
new EventEmitter<MatSliderDragEvent>();
/** Event emitted when the slider thumb stops being dragged. */
@Output() readonly dragEnd: EventEmitter<MatSliderDragEvent> =
new EventEmitter<MatSliderDragEvent>();
/**
* 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<void>();
/**
* 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();
}
}