From 714a9405ade29eb9ad53cd5ed28b46a697b646b9 Mon Sep 17 00:00:00 2001 From: DavitAbgaryan Date: Mon, 30 May 2022 11:09:08 +0400 Subject: [PATCH 1/5] feat: Implement range slider --- .../assets/styles/components/slider.scss | 12 + .../src/lib/components/range-slider/index.ts | 1 + .../lib/components/range-slider/public-api.ts | 2 + .../range-slider/range-slider.component.html | 22 + .../range-slider.component.spec.ts | 24 + .../range-slider/range-slider.component.ts | 859 ++++++++++++++++++ .../range-slider/range-slider.config.ts | 2 + .../range-slider/range-slider.module.ts | 12 + .../range-slider/renge-slider.stories.ts | 114 +++ .../lib/components/slider/slider.stories.ts | 2 +- 10 files changed, 1049 insertions(+), 1 deletion(-) create mode 100644 projects/supernova/src/lib/components/range-slider/index.ts create mode 100644 projects/supernova/src/lib/components/range-slider/public-api.ts create mode 100644 projects/supernova/src/lib/components/range-slider/range-slider.component.html create mode 100644 projects/supernova/src/lib/components/range-slider/range-slider.component.spec.ts create mode 100644 projects/supernova/src/lib/components/range-slider/range-slider.component.ts create mode 100644 projects/supernova/src/lib/components/range-slider/range-slider.config.ts create mode 100644 projects/supernova/src/lib/components/range-slider/range-slider.module.ts create mode 100644 projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts diff --git a/projects/supernova/assets/styles/components/slider.scss b/projects/supernova/assets/styles/components/slider.scss index 1f524e6e..d2985213 100644 --- a/projects/supernova/assets/styles/components/slider.scss +++ b/projects/supernova/assets/styles/components/slider.scss @@ -19,6 +19,18 @@ } } + &:not(.sn-slider-focused-start) { + .sn-slider-thumb-label.start { + opacity: 0!important; + } + } + + &:not(.sn-slider-focused-end) { + .sn-slider-thumb-label.end { + opacity: 0!important; + } + } + &.sn-slider-horizontal { height: slider.$slider-thumb-size; min-width: 128px; diff --git a/projects/supernova/src/lib/components/range-slider/index.ts b/projects/supernova/src/lib/components/range-slider/index.ts new file mode 100644 index 00000000..7e1a213e --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/projects/supernova/src/lib/components/range-slider/public-api.ts b/projects/supernova/src/lib/components/range-slider/public-api.ts new file mode 100644 index 00000000..babbd002 --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/public-api.ts @@ -0,0 +1,2 @@ +export * from './range-slider.module'; +export * from './range-slider.component'; diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.component.html b/projects/supernova/src/lib/components/range-slider/range-slider.component.html new file mode 100644 index 00000000..1e72d35b --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/range-slider.component.html @@ -0,0 +1,22 @@ +
+
+
+
+
+ +
+
+
+ {{ displayValueMin }} +
+
+
+ +
+
+
+ {{ displayValueMax }} +
+
+
+
diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.component.spec.ts b/projects/supernova/src/lib/components/range-slider/range-slider.component.spec.ts new file mode 100644 index 00000000..81270327 --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/range-slider.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RangeSlider } from './range-slider.component'; + +describe('SliderComponent', () => { + let component: RangeSlider; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RangeSlider], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RangeSlider); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.component.ts b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts new file mode 100644 index 00000000..67e0dd41 --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts @@ -0,0 +1,859 @@ +import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; +import { Directionality } from '@angular/cdk/bidi'; +import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; +import { + DOWN_ARROW, + END, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, + hasModifierKey, +} from '@angular/cdk/keycodes'; +import { + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + forwardRef, + Inject, + Input, + OnDestroy, + Optional, + Output, + ViewChild, + ViewEncapsulation, + NgZone, + AfterViewInit, + HostBinding, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; +import { normalizePassiveListenerOptions } from '@angular/cdk/platform'; +import { DOCUMENT } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { mixinTabIndex, mixinDisabled } from '../../mixins'; +import { SliderColor } from './range-slider.config'; + +const activeEventOptions = normalizePassiveListenerOptions({ passive: false }); + +/** + * Provider Expression that allows mat-slider to register as a ControlValueAccessor. + * This allows it to support [(ngModel)] and [formControl]. + * @docs-private + */ +export const SLIDER_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RangeSlider), + multi: true, +}; + +/** A simple change event emitted by the Slider component. */ +export class SliderChange { + source: RangeSlider; + value: [number, number] | null; +} + +const _SliderBase = mixinTabIndex( + mixinDisabled( + class { + constructor(public _elementRef: ElementRef) {} + } + ) +); + +/** + * Allows users to select from a range of values by moving the slider thumb. It is similar in + * behavior to the native `` element. + */ +@Component({ + selector: 'sn-range-slider', + exportAs: 'snRangeSlider', + providers: [SLIDER_VALUE_ACCESSOR], + host: { + '(focus)': '_onFocus()', + '(blur)': '_onBlur()', + '(keyup)': '_onKeyup()', + '(keydown)': '_onKeydown($event)', + '(mouseenter)': '_onMouseenter()', + + // On Safari starting to slide temporarily triggers text selection mode which + // show the wrong cursor. We prevent it by stopping the `selectstart` event. + '(selectstart)': '$event.preventDefault()', + class: 'sn-slider sn-focus-indicator', + role: 'slider', + '[tabIndex]': 'tabIndex', + '[attr.aria-disabled]': 'disabled', + '[attr.aria-valuemax]': 'max', + '[attr.aria-valuemin]': 'min', + '[attr.aria-valuenow]': 'value', + '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', + '[class.sn-slider-disabled]': 'disabled', + '[class.sn-slider-horizontal]': '!vertical', + '[class.sn-slider-axis-inverted]': '_shouldInvertAxis()', + '[class.sn-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', + '[class.sn-slider-vertical]': 'vertical', + '[class.sn-slider-sliding]': '_isSliding', + '[class.sn-slider-focused-start]': '_focusIndex === 0', + '[class.sn-slider-focused-end]': '_focusIndex === 1', + }, + templateUrl: 'range-slider.component.html', + inputs: ['disabled', 'tabIndex'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// TODO extend from another class. Both this and Slider component +export class RangeSlider extends _SliderBase implements ControlValueAccessor, OnDestroy, AfterViewInit { + /** Whether the slider is inverted. */ + @Input() + get invert(): boolean { + return this._invert; + } + set invert(value: boolean) { + this._invert = coerceBooleanProperty(value); + } + private _invert = false; + + /** The maximum value that the slider can have. */ + @Input() + get max(): number { + return this._max; + } + set max(v: number) { + this._max = coerceNumberProperty(v, this._max); + this._percent[1] = this._calculatePercentage(this._value?.[1]); + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + private _max: number = 100; + + /** The minimum value that the slider can have. */ + @Input() + get min(): number { + return this._min; + } + set min(v: number) { + this._min = coerceNumberProperty(v, this._min); + this._percent[0] = this._calculatePercentage(this._value?.[0]); + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + private _min: number = 0; + + /** The values at which the thumb will snap. */ + @Input() + get step(): number { + return this._step; + } + set step(v: number) { + this._step = coerceNumberProperty(v, this._step); + + if (this._step % 1 !== 0) { + this._roundToDecimal = this._step.toString().split('.').pop()!.length; + } + + // Since this could modify the label, we need to notify the change detection. + this._changeDetectorRef.markForCheck(); + } + private _step: number = 1; + + /** Whether or not to show the thumb label. */ + @Input() + get thumbLabel(): boolean { + return this._thumbLabel; + } + set thumbLabel(value: boolean) { + this._thumbLabel = coerceBooleanProperty(value); + } + private _thumbLabel: boolean = true; + + /** Value of the slider. */ + @Input() + get value(): [number, number] { + // If the value needs to be read and it is still uninitialized, initialize it to the min. + if (this._value === null) { + this.value = [this._min, this._max]; + } + return this._value as [number, number]; + } + set value(v: [number, number]) { + if (v[0] !== this._value?.[0] || v[1] !== this._value?.[1]) { + let value: [number, number] = [coerceNumberProperty(v[0], 0), coerceNumberProperty(v[1], 0)]; + + // While incrementing by a decimal we can end up with values like 33.300000000000004. + // Truncate it to ensure that it matches the label and to make it easier to work with. + if (this._roundToDecimal) { + value = [parseFloat(value[0].toFixed(this._roundToDecimal)), parseFloat(value[1].toFixed(this._roundToDecimal))]; + } + + this._value = value; + this._percent = [this._calculatePercentage(this._value[0]), this._calculatePercentage(this._value[1])]; + + // Since this also modifies the percentage, we need to let the change detection know. + this._changeDetectorRef.markForCheck(); + } + } + private _value: [number, number] | null = null; + + /** + * Function that will be used to format the value before it is displayed + * in the thumb label. Can be used to format very large number in order + * for them to fit into the slider thumb. + */ + @Input() displayWith: (value: number) => string | number; + + /** Whether the slider is vertical. */ + @Input() + get vertical(): boolean { + return this._vertical; + } + set vertical(value: boolean) { + this._vertical = coerceBooleanProperty(value); + } + private _vertical = false; + + @Input() + get color(): SliderColor { + return this._color || 'primary'; + } + set color(newValue: SliderColor) { + this._color = newValue; + } + private _color: SliderColor; + + @HostBinding('class') get switchClasses() { + return { + [`sn-slider-${this.color}`]: this.color, + }; + } + + /** Event emitted when the slider value has changed. */ + @Output() readonly change: EventEmitter = new EventEmitter(); + + /** Event emitted when the slider thumb moves. */ + @Output() readonly input: EventEmitter = new EventEmitter(); + + /** + * Emits when the raw value of the slider changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private + */ + @Output() readonly valueChange: EventEmitter<[number, number] | null> = new EventEmitter<[number, number] | null>(); + + /** The value to be used for display purposes. */ + displayValue(focusIndex: 0 | 1): string | number { + if (this.displayWith) { + // Value is never null but since setters and getters cannot have + // different types, the value getter is also typed to return null. + return this.displayWith(this.value[focusIndex]!); + } + + // Note that this could be improved further by rounding something like 0.999 to 1 or + // 0.899 to 0.9, however it is very performance sensitive, because it gets called on + // every change detection cycle. + if (this._roundToDecimal && this.value?.[focusIndex] && this.value[focusIndex] % 1 !== 0) { + return this.value[focusIndex].toFixed(this._roundToDecimal); + } + + return this.value[focusIndex] || 0; + } + + get displayValueMin(): string | number { + return this.displayValue(0); + } + + get displayValueMax(): string | number { + return this.displayValue(1); + } + + /** set focus to the host element */ + focus(options?: FocusOptions) { + this._focusHostElement(options); + } + + /** blur the host element */ + blur() { + this._blurHostElement(); + } + + /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ + onTouched: () => any = () => {}; + + /** The percentage of the slider that coincides with the value. */ + get percent(): [number, number] { + const min = this._clamp(this._percent[0], 0, this._percent[1]); + const max = this._clamp(this._percent[1], this._percent[0], 1); + return [min, max]; + } + private _percent: [number, number] = [0, 1]; + + _focusIndex: 0 | 1 = 0; + + /** + * Whether or not the thumb is sliding and what the user is using to slide it with. + * Used to determine if there should be a transition for the thumb and fill track. + */ + _isSliding: 'keyboard' | 'pointer' | null = null; + + /** + * Whether or not the slider is active (clicked or sliding). + */ + _isActive: boolean = false; + + /** + * Whether the axis of the slider is inverted. + * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). + */ + _shouldInvertAxis() { + // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to + // top. However from a y-axis standpoint this is inverted. + return this.vertical ? !this.invert : this.invert; + } + + /** CSS styles for the track fill element. */ + _getTrackFillStyles(): { [key: string]: string } { + const percent = this.percent; + const percentDiff = percent[1] - percent[0]; + const axis = this.vertical ? 'Y' : 'X'; + const scale = this.vertical ? `1, ${percentDiff}, 1` : `${percentDiff}, 1, 1`; + const sign = this._shouldInvertMouseCoords() ? '-' : ''; + + return { + // scale3d avoids some rendering issues in Chrome. See #12071. + transform: `translate${axis}(${sign}${percent[0] * 100}%) scale3d(${scale})`, + // iOS Safari has a bug where it won't re-render elements which start of as `scale(0)` until + // something forces a style recalculation on it. Since we'll end up with `scale(0)` when + // the value of the slider is 0, we can easily get into this situation. We force a + // recalculation by changing the element's `display` when it goes from 0 to any other value. + display: percent[0] === percent[1] ? 'none' : '', + }; + } + + _getThumbContainerStyles(focusIndex: 0 | 1): { [key: string]: string } { + const shouldInvertAxis = this._shouldInvertAxis(); + let axis = this.vertical ? 'Y' : 'X'; + // For a horizontal slider in RTL languages we push the thumb container off the left edge + // instead of the right edge to avoid causing a horizontal scrollbar to appear. + let invertOffset = this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + let offset = (invertOffset ? this.percent[focusIndex] : 1 - this.percent[focusIndex]) * 100; + return { + transform: `translate${axis}(-${offset}%)`, + }; + } + + /** The dimensions of the slider. */ + private _sliderDimensions: ClientRect | null = null; + + private _controlValueAccessorChangeFn: (value: any) => void = () => {}; + + /** Decimal places to round to, based on the step amount. */ + private _roundToDecimal: number; + + /** Subscription to the Directionality change EventEmitter. */ + private _dirChangeSubscription = Subscription.EMPTY; + + /** The value of the slider when the slide start event fires. */ + private _valueOnSlideStart: [number, number] | null; + + /** Reference to the inner slider wrapper element. */ + @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; + /** + * Whether mouse events should be converted to a slider position by calculating their distance + * from the right or bottom edge of the slider as opposed to the top or left. + */ + _shouldInvertMouseCoords() { + const shouldInvertAxis = this._shouldInvertAxis(); + return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + } + + /** The language direction for this slider element. */ + private _getDirection() { + return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; + } + + /** Keeps track of the last pointer event that was captured by the slider. */ + private _lastPointerEvent: MouseEvent | TouchEvent | null; + + /** Used to subscribe to global move and end events */ + protected _document: Document; + + /** + * Identifier used to attribute a touch event to a particular slider. + * Will be undefined if one of the following conditions is true: + * - The user isn't dragging using a touch device. + * - The browser doesn't support `Touch.identifier`. + * - Dragging hasn't started yet. + */ + private _touchId: number | undefined; + + constructor( + elementRef: ElementRef, + private _focusMonitor: FocusMonitor, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() private _dir: Directionality, + @Attribute('tabindex') tabIndex: string, + private _ngZone: NgZone, + @Inject(DOCUMENT) _document: any, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string + ) { + super(elementRef); + this._document = _document; + this.tabIndex = parseInt(tabIndex) || 0; + + _ngZone.runOutsideAngular(() => { + const element = elementRef.nativeElement; + element.addEventListener('mousedown', this._pointerDown, activeEventOptions); + element.addEventListener('touchstart', this._pointerDown, activeEventOptions); + }); + } + + ngAfterViewInit() { + this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { + this._isActive = !!origin && origin !== 'keyboard'; + this._changeDetectorRef.detectChanges(); + }); + if (this._dir) { + this._dirChangeSubscription = this._dir.change.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); + } + } + + ngOnDestroy() { + const element = this._elementRef.nativeElement; + element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); + element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); + this._lastPointerEvent = null; + this._removeGlobalEvents(); + this._focusMonitor.stopMonitoring(this._elementRef); + this._dirChangeSubscription.unsubscribe(); + } + + _onMouseenter() { + if (this.disabled) { + return; + } + + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + } + + _onFocus() { + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + } + + _onBlur() { + this.onTouched(); + } + + _onKeydown(event: KeyboardEvent) { + if (this.disabled || hasModifierKey(event) || (this._isSliding && this._isSliding !== 'keyboard')) { + return; + } + + const oldValue = this.value; + + switch (event.keyCode) { + case PAGE_UP: + this._increment(10); + break; + case PAGE_DOWN: + this._increment(-10); + break; + case END: + this._increment(this.max - this.min); + break; + case HOME: + this._increment(this.max - this.min); + break; + case LEFT_ARROW: + this._increment(this._getDirection() == 'rtl' || this.invert ? 1 : -1); + break; + case UP_ARROW: + this._increment(1); + break; + case RIGHT_ARROW: + this._increment(this._getDirection() == 'rtl' || this.invert ? -1 : 1); + break; + case DOWN_ARROW: + this._increment(-1); + break; + default: + // Return if the key is not one that we explicitly handle to avoid calling preventDefault on + // it. + return; + } + + if (oldValue != this.value) { + this._emitInputEvent(); + this._emitChangeEvent(); + } + + this._isSliding = 'keyboard'; + event.preventDefault(); + } + + _onKeyup() { + if (this._isSliding === 'keyboard') { + this._isSliding = null; + } + } + + /** Called when the user has put their pointer down on the slider. */ + private _pointerDown = (event: TouchEvent | MouseEvent) => { + // Don't do anything if the slider is disabled or the + // user is using anything other than the main mouse button. + if (this.disabled || this._isSliding || (!isTouchEvent(event) && event.button !== 0)) { + return; + } + + this._ngZone.run(() => { + this._touchId = isTouchEvent(event) ? getTouchIdForSlider(event, this._elementRef.nativeElement) : undefined; + const pointerPosition = getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + const oldValue = this.value; + this._isSliding = 'pointer'; + this._lastPointerEvent = event; + event.preventDefault(); + this._focusHostElement(); + this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. + this._bindGlobalEvents(event); + this._focusHostElement(); + this._updateValueFromPosition(pointerPosition, true); + this._valueOnSlideStart = oldValue; + + // Emit a change and input event if the value changed. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + }); + }; + + /** + * Called when the user has moved their pointer after + * starting to drag. Bound on the document level. + */ + private _pointerMove = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + const pointerPosition = getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + // Prevent the slide from selecting anything else. + event.preventDefault(); + const oldValue = this.value; + this._lastPointerEvent = event; + this._updateValueFromPosition(pointerPosition, false); + + // Native range elements always emit `input` events when the value changed while sliding. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + } + }; + + /** Called when the user has lifted their pointer. Bound on the document level. */ + private _pointerUp = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + if ( + !isTouchEvent(event) || + typeof this._touchId !== 'number' || + // Note that we use `changedTouches`, rather than `touches` because it + // seems like in most cases `touches` is empty for `touchend` events. + findMatchingTouch(event.changedTouches, this._touchId) + ) { + event.preventDefault(); + this._removeGlobalEvents(); + this._isSliding = null; + this._touchId = undefined; + + if (this._valueOnSlideStart != this.value && !this.disabled) { + this._emitChangeEvent(); + } + + this._valueOnSlideStart = this._lastPointerEvent = null; + } + } + }; + + /** Called when the window has lost focus. */ + private _windowBlur = () => { + // If the window is blurred while dragging we need to stop dragging because the + // browser won't dispatch the `mouseup` and `touchend` events anymore. + if (this._lastPointerEvent) { + this._pointerUp(this._lastPointerEvent); + } + }; + + /** Use defaultView of injected document if available or fallback to global window reference */ + private _getWindow(): Window { + return this._document.defaultView || window; + } + + /** + * Binds our global move and end events. They're bound at the document level and only while + * dragging so that the user doesn't have to keep their pointer exactly over the slider + * as they're swiping across the screen. + */ + private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { + // Note that we bind the events to the `document`, because it allows us to capture + // drag cancel events where the user's pointer is outside the browser window. + const document = this._document; + const isTouch = isTouchEvent(triggerEvent); + const moveEventName = isTouch ? 'touchmove' : 'mousemove'; + const endEventName = isTouch ? 'touchend' : 'mouseup'; + document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); + document.addEventListener(endEventName, this._pointerUp, activeEventOptions); + + if (isTouch) { + document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); + } + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.addEventListener('blur', this._windowBlur); + } + } + + /** Removes any global event listeners that we may have added. */ + private _removeGlobalEvents() { + const document = this._document; + document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); + document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); + document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); + document.removeEventListener('touchend', this._pointerUp, activeEventOptions); + document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.removeEventListener('blur', this._windowBlur); + } + } + + /** Increments the slider by the given number of steps (negative number decrements). */ + private _increment(numSteps: number) { + const rangeValue: [number, number] = [...this.value]; + rangeValue[this._focusIndex] = this._focusIndex ? this.max : this.min; + rangeValue[this._focusIndex] = this._clamp((this.value[this._focusIndex] || 0) + this.step * numSteps, ...rangeValue); + this.value = rangeValue; + } + + /** Calculate the new value from the new physical location. The value will always be snapped. */ + private _updateValueFromPosition(pos: { x: number; y: number }, setFocusIndex: boolean) { + if (!this._sliderDimensions) { + return; + } + + let offset = this.vertical ? this._sliderDimensions.top : this._sliderDimensions.left; + let size = this.vertical ? this._sliderDimensions.height : this._sliderDimensions.width; + let posComponent = this.vertical ? pos.y : pos.x; + let roughPercent = (posComponent - offset) / size; + if (this._shouldInvertMouseCoords()) { + roughPercent = 1 - roughPercent; + } + const middlePercent = (this.percent[0] + this.percent[1]) / 2; + if (setFocusIndex) { + this._focusIndex = roughPercent < middlePercent ? 0 : 1; + } + + // The exact value is calculated from the event and used to find the closest snap value. + const range = [0, 1]; + range[1 - this._focusIndex] = this.percent[1 - this._focusIndex]; + let percent = this._clamp(roughPercent, ...range); + + // Since the steps may not divide cleanly into the max value, if the user + // slid to 0 or 100 percent, we jump to the min/max value. This approach + // is slightly more intuitive than using `Math.ceil` below, because it + // follows the user's pointer closer.] + let value; + if (percent === 0) { + value = this.min; + } else if (percent === 1) { + value = this.max; + } else { + const exactValue = this._calculateValue(percent); + + // This calculation finds the closest step by finding the closest + // whole number divisible by the step relative to the min. + const closestValue = Math.round((exactValue - this.min) / this.step) * this.step + this.min; + + // The value needs to snap to the min and max. + value = this._clamp(closestValue, this.min, this.max); + } + + const rangeValue: [number, number] = [...this.value]; + rangeValue[this._focusIndex] = value; + this.value = rangeValue; + } + + /** Emits a change event if the current value is different from the last emitted value. */ + private _emitChangeEvent() { + this._controlValueAccessorChangeFn(this.value); + this.valueChange.emit(this.value); + this.change.emit(this._createChangeEvent()); + } + + /** Emits an input event when the current value is different from the last emitted value. */ + private _emitInputEvent() { + this.input.emit(this._createChangeEvent()); + } + + /** Creates a slider change object from the specified value. */ + private _createChangeEvent(value = this.value): SliderChange { + let event = new SliderChange(); + + event.source = this; + event.value = value; + + return event; + } + + /** Calculates the percentage of the slider that a value is. */ + private _calculatePercentage(value: number | null) { + return ((value || 0) - this.min) / (this.max - this.min); + } + + /** Calculates the value a percentage of the slider corresponds to. */ + private _calculateValue(percentage: number) { + return this.min + percentage * (this.max - this.min); + } + + /** Return a number between two numbers. */ + private _clamp(value: number, min = 0, max = 1) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Get the bounding client rect of the slider track element. + * The track is used rather than the native element to ignore the extra space that the thumb can + * take up. + */ + private _getSliderDimensions() { + return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; + } + + /** + * Focuses the native element. + * Currently only used to allow a blur event to fire but will be used with keyboard input later. + */ + private _focusHostElement(options?: FocusOptions) { + this._elementRef.nativeElement.focus(options); + } + + /** Blurs the native element. */ + private _blurHostElement() { + this._elementRef.nativeElement.blur(); + } + + /** + * Sets the model value. Implemented as part of ControlValueAccessor. + * @param value + */ + writeValue(value: any) { + this.value = value; + } + + /** + * Registers a callback to be triggered when the value has changed. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnChange(fn: (value: any) => void) { + this._controlValueAccessorChangeFn = fn; + } + + /** + * Registers a callback to be triggered when the component is touched. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** + * Sets whether the component should be disabled. + * Implemented as part of ControlValueAccessor. + * @param isDisabled + */ + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + static ngAcceptInputType_invert: BooleanInput; + static ngAcceptInputType_max: NumberInput; + static ngAcceptInputType_min: NumberInput; + static ngAcceptInputType_step: NumberInput; + static ngAcceptInputType_thumbLabel: BooleanInput; + static ngAcceptInputType_value: NumberInput; + static ngAcceptInputType_vertical: BooleanInput; + static ngAcceptInputType_disabled: BooleanInput; + static ngAcceptInputType_tabIndex: NumberInput; +} + +/** Returns whether an event is a touch event. */ +function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + // This function is called for every pixel that the user has dragged so we need it to be + // as fast as possible. Since we only bind mouse events and touch events, we can assume + // that if the event's name starts with `t`, it's a touch event. + return event.type[0] === 't'; +} + +/** Gets the coordinates of a touch or mouse event relative to the viewport. */ +function getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { + let point: { clientX: number; clientY: number } | undefined; + + if (isTouchEvent(event)) { + // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. + // If that's the case, attribute the first touch to all active sliders. This should still cover + // the most common case while only breaking multi-touch. + if (typeof id === 'number') { + point = findMatchingTouch(event.touches, id) || findMatchingTouch(event.changedTouches, id); + } else { + // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. + point = event.touches[0] || event.changedTouches[0]; + } + } else { + point = event; + } + + return point ? { x: point.clientX, y: point.clientY } : undefined; +} + +/** Finds a `Touch` with a specific ID in a `TouchList`. */ +function findMatchingTouch(touches: TouchList, id: number): Touch | undefined { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) { + return touches[i]; + } + } + + return undefined; +} + +/** Gets the unique ID of a touch that matches a specific slider. */ +function getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { + for (let i = 0; i < event.touches.length; i++) { + const target = event.touches[i].target as HTMLElement; + + if (sliderHost === target || sliderHost.contains(target)) { + return event.touches[i].identifier; + } + } + + return undefined; +} diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.config.ts b/projects/supernova/src/lib/components/range-slider/range-slider.config.ts new file mode 100644 index 00000000..5c6d9104 --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/range-slider.config.ts @@ -0,0 +1,2 @@ +type CustomSliderColor = string; +export type SliderColor = 'primary' | CustomSliderColor; diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.module.ts b/projects/supernova/src/lib/components/range-slider/range-slider.module.ts new file mode 100644 index 00000000..386df066 --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/range-slider.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { TooltipModule } from '../tooltip'; +import { RangeSlider } from './range-slider.component'; + +@NgModule({ + imports: [CommonModule, TooltipModule], + exports: [RangeSlider], + declarations: [RangeSlider], +}) +export class RangeSliderModule {} diff --git a/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts new file mode 100644 index 00000000..3a2ae22c --- /dev/null +++ b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts @@ -0,0 +1,114 @@ +import { Meta, Story, moduleMetadata } from '@storybook/angular'; +import { Component, Input } from '@angular/core'; + +import { RangeSliderModule } from './range-slider.module'; + +import { CheckboxModule } from '../checkbox'; +import { SliderChange } from './range-slider.component'; + +@Component({ + selector: 'range-slider-example', + template: ` + + `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + } + pre { + padding: 16px; + margin: 20px 0; + width: 100%; + display: block; + background: #eee; + border-radius: 8px; + color: green; + } + .sn-slider-group { + } + .sn-checkbox, + .sn-slider { + margin: 35px 0; + } + `, + ], +}) +class RangeSliderComponent { + @Input() max: number; + @Input() min: number; + @Input() step: number; + @Input() value: [number, number]; + @Input() disabled: boolean; + @Input() thumbLabel: boolean; + @Input() vertical: boolean; + @Input() invert: boolean; + @Input() displayWith: (value: number) => string | number; + + public output: [number, number] | null; + + change(e: SliderChange): void { + this.output = e.value; + } + + displayWithPercent(v: number) { + return `${v}%`; + } +} + +export default { + title: 'Range Slider', + selector: 'range-slider', + decorators: [ + moduleMetadata({ + declarations: [RangeSliderComponent], + imports: [RangeSliderModule, CheckboxModule], + }), + ], +} as Meta; + +const Template: Story = (args: RangeSliderComponent) => ({ + props: args, + component: RangeSliderComponent, +}); + +export const SliderDefault = Template.bind({}); + +SliderDefault.args = { + max: 100, + min: 0, + step: 1, + value: [40, 50], + thumbLabel: true, + vertical: false, + disabled: false, + invert: false, +}; + +SliderDefault.storyName = 'Default slider'; + +export const SliderVertical = Template.bind({}); + +SliderVertical.args = { + max: 100, + min: 0, + step: 1, + value: [20, 60], + thumbLabel: true, + vertical: true, + disabled: false, + invert: false, +}; + +SliderVertical.storyName = 'Vertical slider'; diff --git a/projects/supernova/src/lib/components/slider/slider.stories.ts b/projects/supernova/src/lib/components/slider/slider.stories.ts index 95bc412f..ed02ce3e 100644 --- a/projects/supernova/src/lib/components/slider/slider.stories.ts +++ b/projects/supernova/src/lib/components/slider/slider.stories.ts @@ -41,7 +41,7 @@ import { SliderChange } from './slider.component'; } .sn-checkbox, .sn-slider { - margin: 5px 0; + margin: 35px 0; } `, ], From 291eead0ab48eff2a5b912c54c229ed12ee2ccc0 Mon Sep 17 00:00:00 2001 From: DavitAbgaryan Date: Mon, 30 May 2022 11:09:46 +0400 Subject: [PATCH 2/5] feat: Change calendar input icon --- .../supernova/assets/styles/components/input.scss | 13 +++++++++++++ .../supernova/assets/styles/variables/input.scss | 1 + 2 files changed, 14 insertions(+) diff --git a/projects/supernova/assets/styles/components/input.scss b/projects/supernova/assets/styles/components/input.scss index 7cd38a5b..e6f74c5e 100644 --- a/projects/supernova/assets/styles/components/input.scss +++ b/projects/supernova/assets/styles/components/input.scss @@ -92,3 +92,16 @@ input[type='search']::-webkit-search-cancel-button { background-repeat: no-repeat; background-position: top left; } + +input[type='date']::-webkit-calendar-picker-indicator { + color: rgba(0, 0, 0, 0); + opacity: 1; + display: block; + background-image: url(input.$input-calendar-button); + background-repeat: no-repeat; + background-position: 2px 0; + vertical-align: middle; + width: 24px; + height: 24px; + border-width: thin; +} diff --git a/projects/supernova/assets/styles/variables/input.scss b/projects/supernova/assets/styles/variables/input.scss index 19d688bc..6a9b6952 100644 --- a/projects/supernova/assets/styles/variables/input.scss +++ b/projects/supernova/assets/styles/variables/input.scss @@ -21,6 +21,7 @@ $input-disabled-border-color: map.get(colors.$palette, neutral-500) !default; $inpur-inner-spin-button: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTciIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAxNyAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTUuMTc3NTEgOS4zMTk3NEM1LjE3OTk5IDkuMjMxNjIgNS4xOTg5OSA5LjE0NDc1IDUuMjMzNTMgOS4wNjM2NUM1LjI3MTg5IDguOTgzNjkgNS4zMjMyOCA4LjkxMDY3IDUuMzg1NTkgOC44NDc1N0w4LjA0MjU5IDYuMTkwNTZDOC4xNjkzOCA2LjA2ODMxIDguMzM4NjQgNiA4LjUxNDc3IDZDOC42OTA4OSA2IDguODYwMTYgNi4wNjgzMSA4Ljk4Njk0IDYuMTkwNTZMMTEuNjQzOSA4Ljg0NzU3QzExLjc1NjQgOC45NzYxNyAxMS44MTg5IDkuMTQwOTEgMTEuODIgOS4zMTE3NEMxMS44MTk5IDkuMzk2NzIgMTEuODAyOCA5LjQ4MDgyIDExLjc2OTggOS41NTkxNEMxMS43MzY5IDkuNjM3NDYgMTEuNjg4NiA5LjcwODQzIDExLjYyNzkgOS43Njc5MUMxMS41MSA5LjkwMzU3IDExLjM0MzEgOS45ODcwMSAxMS4xNjM4IDEwQzEwLjk5MjkgOS45OTg4OCAxMC44MjgyIDkuOTM2MzkgMTAuNjk5NiA5LjgyMzkzTDguNDk4NzYgNy41OTkxTDYuMzEzOTQgOS43ODM5MkM2LjI1MDgzIDkuODQ2MjMgNi4xNzc4MSA5Ljg5NzYxIDYuMDk3ODYgOS45MzU5N0M2LjAxNDI2IDkuOTcxNTEgNS45MjQ1OCA5Ljk5MDUzIDUuODMzNzYgOS45OTJDNS43NDYxMyA5Ljk5MjI1IDUuNjU5MjQgOS45NzU5NiA1LjU3NzY2IDkuOTQzOThDNS40OTM0MiA5LjkxMjczIDUuNDE2OTEgOS44NjM2NiA1LjM1MzM4IDkuODAwMTJDNS4yODk4NCA5LjczNjU4IDUuMjQwNzcgOS42NjAwOCA1LjIwOTUyIDkuNTc1ODRDNS4xODMxNyA5LjQ5MzE3IDUuMTcyMzIgOS40MDYzNSA1LjE3NzUxIDkuMzE5NzRaIiBmaWxsPSIjMUQxRDFEIi8+CjxwYXRoIGQ9Ik0xMS44MjI1IDIyLjY4MDNDMTEuODIgMjIuNzY4NCAxMS44MDEgMjIuODU1MiAxMS43NjY1IDIyLjkzNjRDMTEuNzI4MSAyMy4wMTYzIDExLjY3NjcgMjMuMDg5MyAxMS42MTQ0IDIzLjE1MjRMOC45NTc0MSAyNS44MDk0QzguODMwNjIgMjUuOTMxNyA4LjY2MTM2IDI2IDguNDg1MjMgMjZDOC4zMDkxMSAyNiA4LjEzOTg0IDI1LjkzMTcgOC4wMTMwNiAyNS44MDk0TDUuMzU2MDYgMjMuMTUyNEM1LjI0MzYgMjMuMDIzOCA1LjE4MTExIDIyLjg1OTEgNS4xNzk5OSAyMi42ODgzQzUuMTgwMTMgMjIuNjAzMyA1LjE5NzE5IDIyLjUxOTIgNS4yMzAxNiAyMi40NDA5QzUuMjYzMTQgMjIuMzYyNSA1LjMxMTM4IDIyLjI5MTYgNS4zNzIwNiAyMi4yMzIxQzUuNDkwMDQgMjIuMDk2NCA1LjY1NjkyIDIyLjAxMyA1LjgzNjI0IDIyQzYuMDA3MDcgMjIuMDAxMSA2LjE3MTgxIDIyLjA2MzYgNi4zMDA0MSAyMi4xNzYxTDguNTAxMjQgMjQuNDAwOUwxMC42ODYxIDIyLjIxNjFDMTAuNzQ5MiAyMi4xNTM4IDEwLjgyMjIgMjIuMTAyNCAxMC45MDIxIDIyLjA2NEMxMC45ODU3IDIyLjAyODUgMTEuMDc1NCAyMi4wMDk1IDExLjE2NjIgMjIuMDA4QzExLjI1MzkgMjIuMDA3OCAxMS4zNDA4IDIyLjAyNCAxMS40MjIzIDIyLjA1NkMxMS41MDY2IDIyLjA4NzMgMTEuNTgzMSAyMi4xMzYzIDExLjY0NjYgMjIuMTk5OUMxMS43MTAyIDIyLjI2MzQgMTEuNzU5MiAyMi4zMzk5IDExLjc5MDUgMjIuNDI0MkMxMS44MTY4IDIyLjUwNjggMTEuODI3NyAyMi41OTM2IDExLjgyMjUgMjIuNjgwM1oiIGZpbGw9IiMxRDFEMUQiLz4KPC9zdmc+Cg==' !default; $inpur-search-cancel-button: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2JmYmZiZiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNi4yNDI2IDcuNzU3NDRDMTMuODk5MyA1LjQxNDE4IDEwLjEwMDIgNS40MTQxOCA3Ljc1NzQxIDcuNzU3NDhDNS40MTQxOCAxMC4xMDA3IDUuNDE0MTggMTMuODk5OCA3Ljc1NzUxIDE2LjI0MjZDMTAuMTAwMiAxOC41ODU4IDEzLjg5OTMgMTguNTg1OCAxNi4yNDI2IDE2LjI0MjVDMTguNTg1OCAxMy44OTk4IDE4LjU4NTggMTAuMTAwNyAxNi4yNDI2IDcuNzU3NDRaTTEzLjc2NzcgOS41MjUxOUMxMy45NjMgOS4zMjk5NCAxNC4yNzk2IDkuMzI5OTQgMTQuNDc0OCA5LjUyNTE5QzE0LjY3MDEgOS43MjA0NyAxNC42NzAxIDEwLjAzNyAxNC40NzQ4IDEwLjIzMjNMMTIuNzA3MSAxMi4wMDAxTDE0LjQ3NDcgMTMuNzY3N0MxNC42Njk5IDEzLjk2MyAxNC42Njk5IDE0LjI3OTYgMTQuNDc0NyAxNC40NzQ4QzE0LjI3OTQgMTQuNjcwMSAxMy45NjI4IDE0LjY3MDEgMTMuNzY3NiAxNC40NzQ4TDExLjk5OTkgMTIuNzA3MkwxMC4yMzI4IDE0LjQ3NDNDMTAuMDM3NiAxNC42Njk2IDkuNzIwOTggMTQuNjY5NiA5LjUyNTcyIDE0LjQ3NDNDOS4zMzA0NyAxNC4yNzkgOS4zMzA0NyAxMy45NjI0IDkuNTI1NzIgMTMuNzY3MkwxMS4yOTI4IDEyLjAwMDFMOS41MjUwNyAxMC4yMzIzQzkuMzI5ODEgMTAuMDM3IDkuMzI5ODEgOS43MjA0NSA5LjUyNTA3IDkuNTI1MTlDOS43MjAzMiA5LjMyOTk0IDEwLjAzNjkgOS4zMjk5NCAxMC4yMzIyIDkuNTI1MTlMMTEuOTk5OSAxMS4yOTNMMTMuNzY3NyA5LjUyNTE5WiIgZmlsbD0iI2JmYmZiZiIvPgo8L3N2Zz4K' !default; +$input-calendar-button: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik03LjYxMTQzIDRDNy45OTAxNCA0IDguMjk3MTQgNC4zMDcgOC4yOTcxNCA0LjY4NTcxVjQuNzMxNDNIMTUuNzAyOVY0LjY4NTcxQzE1LjcwMjkgNC4zMDcgMTYuMDA5OSA0IDE2LjM4ODYgNEMxNi43NjczIDQgMTcuMDc0MyA0LjMwNyAxNy4wNzQzIDQuNjg1NzFWNC43MzE0M0gxNy40ODU3QzE4Ljg3NDMgNC43MzE0MyAyMCA1Ljg1NzEyIDIwIDcuMjQ1NzJWOS44MDU3MVYxNy40ODU3QzIwIDE4Ljg3NDMgMTguODc0MyAyMCAxNy40ODU3IDIwSDYuNTE0MjlDNS4xMjU2OCAyMCA0IDE4Ljg3NDMgNCAxNy40ODU3VjkuODA1NzFWNy4yNDU3MkM0IDUuODU3MTIgNS4xMjU2OCA0LjczMTQzIDYuNTE0MjkgNC43MzE0M0g2LjkyNTcxVjQuNjg1NzFDNi45MjU3MSA0LjMwNyA3LjIzMjcyIDQgNy42MTE0MyA0Wk02LjkyNTcxIDcuMjQ1NzFWNi4xMDI4Nkg2LjUxNDI5QzUuODgzMSA2LjEwMjg2IDUuMzcxNDMgNi42MTQ1MyA1LjM3MTQzIDcuMjQ1NzJWOS4xMkgxOC42Mjg2VjcuMjQ1NzJDMTguNjI4NiA2LjYxNDUzIDE4LjExNjkgNi4xMDI4NiAxNy40ODU3IDYuMTAyODZIMTcuMDc0M1Y3LjI0NTcxQzE3LjA3NDMgNy42MjQ0MiAxNi43NjczIDcuOTMxNDMgMTYuMzg4NiA3LjkzMTQzQzE2LjAwOTkgNy45MzE0MyAxNS43MDI5IDcuNjI0NDIgMTUuNzAyOSA3LjI0NTcxVjYuMTAyODZIOC4yOTcxNFY3LjI0NTcxQzguMjk3MTQgNy42MjQ0MiA3Ljk5MDE0IDcuOTMxNDMgNy42MTE0MyA3LjkzMTQzQzcuMjMyNzIgNy45MzE0MyA2LjkyNTcxIDcuNjI0NDIgNi45MjU3MSA3LjI0NTcxWk01LjM3MTQzIDE3LjQ4NTdWMTAuNDkxNEgxOC42Mjg2VjE3LjQ4NTdDMTguNjI4NiAxOC4xMTY5IDE4LjExNjkgMTguNjI4NiAxNy40ODU3IDE4LjYyODZINi41MTQyOUM1Ljg4MzEgMTguNjI4NiA1LjM3MTQzIDE4LjExNjkgNS4zNzE0MyAxNy40ODU3Wk03LjYxMTQ0IDEyLjc3NzFDNy4yMzI3MyAxMi43NzcxIDYuOTI1NzMgMTMuMDg0MSA2LjkyNTczIDEzLjQ2MjlWMTYuMzg4NkM2LjkyNTczIDE2Ljc2NzMgNy4yMzI3MyAxNy4wNzQzIDcuNjExNDQgMTcuMDc0M0gxMC41MzcyQzEwLjkxNTkgMTcuMDc0MyAxMS4yMjI5IDE2Ljc2NzMgMTEuMjIyOSAxNi4zODg2VjEzLjQ2MjlDMTEuMjIyOSAxMy4wODQxIDEwLjkxNTkgMTIuNzc3MSAxMC41MzcyIDEyLjc3NzFINy42MTE0NFpNOC4yOTcxNSAxNS43MDI5VjE0LjE0ODZIOS44NTE0NFYxNS43MDI5SDguMjk3MTVaIiBmaWxsPSIjMUQxRDFEIi8+Cjwvc3ZnPgo=' !default; $input-size-custom-variants: () !default; $input-size-variants: map.merge( From 3935bcda212a46445e3a0707647330e6d9eb7fb9 Mon Sep 17 00:00:00 2001 From: DavitAbgaryan Date: Mon, 30 May 2022 17:49:12 +0400 Subject: [PATCH 3/5] chore: Change date input icon cursor to pointer --- projects/supernova/assets/styles/components/input.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/supernova/assets/styles/components/input.scss b/projects/supernova/assets/styles/components/input.scss index e6f74c5e..7ac1d291 100644 --- a/projects/supernova/assets/styles/components/input.scss +++ b/projects/supernova/assets/styles/components/input.scss @@ -104,4 +104,5 @@ input[type='date']::-webkit-calendar-picker-indicator { width: 24px; height: 24px; border-width: thin; + cursor: pointer; } From cab3f710c4a0fac73fde54581332fac5e9c8164d Mon Sep 17 00:00:00 2001 From: DavitAbgaryan Date: Mon, 30 May 2022 18:33:51 +0400 Subject: [PATCH 4/5] chore: Rename slider references to range slider --- .../range-slider/range-slider.component.ts | 14 +++++++------- .../range-slider/renge-slider.stories.ts | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.component.ts b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts index 67e0dd41..4b145f79 100644 --- a/projects/supernova/src/lib/components/range-slider/range-slider.component.ts +++ b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts @@ -47,14 +47,14 @@ const activeEventOptions = normalizePassiveListenerOptions({ passive: false }); * This allows it to support [(ngModel)] and [formControl]. * @docs-private */ -export const SLIDER_VALUE_ACCESSOR: any = { +export const RANGE_SLIDER_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RangeSlider), multi: true, }; /** A simple change event emitted by the Slider component. */ -export class SliderChange { +export class RangeSliderChange { source: RangeSlider; value: [number, number] | null; } @@ -74,7 +74,7 @@ const _SliderBase = mixinTabIndex( @Component({ selector: 'sn-range-slider', exportAs: 'snRangeSlider', - providers: [SLIDER_VALUE_ACCESSOR], + providers: [RANGE_SLIDER_VALUE_ACCESSOR], host: { '(focus)': '_onFocus()', '(blur)': '_onBlur()', @@ -235,10 +235,10 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } /** Event emitted when the slider value has changed. */ - @Output() readonly change: EventEmitter = new EventEmitter(); + @Output() readonly change: EventEmitter = new EventEmitter(); /** Event emitted when the slider thumb moves. */ - @Output() readonly input: EventEmitter = new EventEmitter(); + @Output() readonly input: EventEmitter = new EventEmitter(); /** * Emits when the raw value of the slider changes. This is here primarily @@ -713,8 +713,8 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } /** Creates a slider change object from the specified value. */ - private _createChangeEvent(value = this.value): SliderChange { - let event = new SliderChange(); + private _createChangeEvent(value = this.value): RangeSliderChange { + let event = new RangeSliderChange(); event.source = this; event.value = value; diff --git a/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts index 3a2ae22c..4d33727b 100644 --- a/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts +++ b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts @@ -4,7 +4,7 @@ import { Component, Input } from '@angular/core'; import { RangeSliderModule } from './range-slider.module'; import { CheckboxModule } from '../checkbox'; -import { SliderChange } from './range-slider.component'; +import { RangeSliderChange } from './range-slider.component'; @Component({ selector: 'range-slider-example', @@ -58,7 +58,7 @@ class RangeSliderComponent { public output: [number, number] | null; - change(e: SliderChange): void { + change(e: RangeSliderChange): void { this.output = e.value; } From 20a4ea661619acc8ba4f110b371bd80d41956a43 Mon Sep 17 00:00:00 2001 From: DavitAbgaryan Date: Thu, 2 Jun 2022 16:31:32 +0400 Subject: [PATCH 5/5] refactor: Slider and Range Slider abstraction --- .../range-slider/range-slider.component.ts | 689 +----------------- .../range-slider/renge-slider.stories.ts | 4 +- .../slider-base/slider-base.component.ts | 658 +++++++++++++++++ .../slider-base.config.ts} | 0 .../slider-base/slider-change.model.ts | 4 + .../lib/components/slider/slider.component.ts | 685 +---------------- .../lib/components/slider/slider.config.ts | 2 - .../lib/components/slider/slider.stories.ts | 4 +- 8 files changed, 698 insertions(+), 1348 deletions(-) create mode 100644 projects/supernova/src/lib/components/slider-base/slider-base.component.ts rename projects/supernova/src/lib/components/{range-slider/range-slider.config.ts => slider-base/slider-base.config.ts} (100%) create mode 100644 projects/supernova/src/lib/components/slider-base/slider-change.model.ts delete mode 100644 projects/supernova/src/lib/components/slider/slider.config.ts diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.component.ts b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts index 4b145f79..325fe40e 100644 --- a/projects/supernova/src/lib/components/range-slider/range-slider.component.ts +++ b/projects/supernova/src/lib/components/range-slider/range-slider.component.ts @@ -1,124 +1,26 @@ -import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; -import { Directionality } from '@angular/cdk/bidi'; -import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; -import { - DOWN_ARROW, - END, - HOME, - LEFT_ARROW, - PAGE_DOWN, - PAGE_UP, - RIGHT_ARROW, - UP_ARROW, - hasModifierKey, -} from '@angular/cdk/keycodes'; -import { - Attribute, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - forwardRef, - Inject, - Input, - OnDestroy, - Optional, - Output, - ViewChild, - ViewEncapsulation, - NgZone, - AfterViewInit, - HostBinding, -} from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { Component, forwardRef, Input } from '@angular/core'; -import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; -import { normalizePassiveListenerOptions } from '@angular/cdk/platform'; -import { DOCUMENT } from '@angular/common'; -import { Subscription } from 'rxjs'; -import { mixinTabIndex, mixinDisabled } from '../../mixins'; -import { SliderColor } from './range-slider.config'; +import { SliderBase } from '../slider-base/slider-base.component'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; -const activeEventOptions = normalizePassiveListenerOptions({ passive: false }); - -/** - * Provider Expression that allows mat-slider to register as a ControlValueAccessor. - * This allows it to support [(ngModel)] and [formControl]. - * @docs-private - */ -export const RANGE_SLIDER_VALUE_ACCESSOR: any = { +export const SLIDER_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RangeSlider), multi: true, }; -/** A simple change event emitted by the Slider component. */ -export class RangeSliderChange { - source: RangeSlider; - value: [number, number] | null; -} - -const _SliderBase = mixinTabIndex( - mixinDisabled( - class { - constructor(public _elementRef: ElementRef) {} - } - ) -); - /** * Allows users to select from a range of values by moving the slider thumb. It is similar in * behavior to the native `` element. */ @Component({ + providers: [SLIDER_VALUE_ACCESSOR], selector: 'sn-range-slider', exportAs: 'snRangeSlider', - providers: [RANGE_SLIDER_VALUE_ACCESSOR], - host: { - '(focus)': '_onFocus()', - '(blur)': '_onBlur()', - '(keyup)': '_onKeyup()', - '(keydown)': '_onKeydown($event)', - '(mouseenter)': '_onMouseenter()', - - // On Safari starting to slide temporarily triggers text selection mode which - // show the wrong cursor. We prevent it by stopping the `selectstart` event. - '(selectstart)': '$event.preventDefault()', - class: 'sn-slider sn-focus-indicator', - role: 'slider', - '[tabIndex]': 'tabIndex', - '[attr.aria-disabled]': 'disabled', - '[attr.aria-valuemax]': 'max', - '[attr.aria-valuemin]': 'min', - '[attr.aria-valuenow]': 'value', - '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', - '[class.sn-slider-disabled]': 'disabled', - '[class.sn-slider-horizontal]': '!vertical', - '[class.sn-slider-axis-inverted]': '_shouldInvertAxis()', - '[class.sn-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', - '[class.sn-slider-vertical]': 'vertical', - '[class.sn-slider-sliding]': '_isSliding', - '[class.sn-slider-focused-start]': '_focusIndex === 0', - '[class.sn-slider-focused-end]': '_focusIndex === 1', - }, templateUrl: 'range-slider.component.html', - inputs: ['disabled', 'tabIndex'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, }) -// TODO extend from another class. Both this and Slider component -export class RangeSlider extends _SliderBase implements ControlValueAccessor, OnDestroy, AfterViewInit { - /** Whether the slider is inverted. */ - @Input() - get invert(): boolean { - return this._invert; - } - set invert(value: boolean) { - this._invert = coerceBooleanProperty(value); - } - private _invert = false; - +export class RangeSlider extends SliderBase<[number, number]> { /** The maximum value that the slider can have. */ @Input() get max(): number { @@ -126,12 +28,12 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } set max(v: number) { this._max = coerceNumberProperty(v, this._max); - this._percent[1] = this._calculatePercentage(this._value?.[1]); + this._percent[1] = this._calculatePercentage(this.value[1]); // Since this also modifies the percentage, we need to let the change detection know. this._changeDetectorRef.markForCheck(); } - private _max: number = 100; + protected _max: number = 100; /** The minimum value that the slider can have. */ @Input() @@ -140,50 +42,24 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } set min(v: number) { this._min = coerceNumberProperty(v, this._min); - this._percent[0] = this._calculatePercentage(this._value?.[0]); + this._percent[0] = this._calculatePercentage(this.value[0]); // Since this also modifies the percentage, we need to let the change detection know. this._changeDetectorRef.markForCheck(); } - private _min: number = 0; - - /** The values at which the thumb will snap. */ - @Input() - get step(): number { - return this._step; - } - set step(v: number) { - this._step = coerceNumberProperty(v, this._step); - - if (this._step % 1 !== 0) { - this._roundToDecimal = this._step.toString().split('.').pop()!.length; - } - - // Since this could modify the label, we need to notify the change detection. - this._changeDetectorRef.markForCheck(); - } - private _step: number = 1; - - /** Whether or not to show the thumb label. */ - @Input() - get thumbLabel(): boolean { - return this._thumbLabel; - } - set thumbLabel(value: boolean) { - this._thumbLabel = coerceBooleanProperty(value); - } - private _thumbLabel: boolean = true; + protected _min: number = 0; /** Value of the slider. */ @Input() get value(): [number, number] { - // If the value needs to be read and it is still uninitialized, initialize it to the min. + // If the value needs to be read, and it is still uninitialized, initialize it to the min. if (this._value === null) { this.value = [this._min, this._max]; } return this._value as [number, number]; } set value(v: [number, number]) { + v = v || [this.min, this.max]; if (v[0] !== this._value?.[0] || v[1] !== this._value?.[1]) { let value: [number, number] = [coerceNumberProperty(v[0], 0), coerceNumberProperty(v[1], 0)]; @@ -200,52 +76,7 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On this._changeDetectorRef.markForCheck(); } } - private _value: [number, number] | null = null; - - /** - * Function that will be used to format the value before it is displayed - * in the thumb label. Can be used to format very large number in order - * for them to fit into the slider thumb. - */ - @Input() displayWith: (value: number) => string | number; - - /** Whether the slider is vertical. */ - @Input() - get vertical(): boolean { - return this._vertical; - } - set vertical(value: boolean) { - this._vertical = coerceBooleanProperty(value); - } - private _vertical = false; - - @Input() - get color(): SliderColor { - return this._color || 'primary'; - } - set color(newValue: SliderColor) { - this._color = newValue; - } - private _color: SliderColor; - - @HostBinding('class') get switchClasses() { - return { - [`sn-slider-${this.color}`]: this.color, - }; - } - - /** Event emitted when the slider value has changed. */ - @Output() readonly change: EventEmitter = new EventEmitter(); - - /** Event emitted when the slider thumb moves. */ - @Output() readonly input: EventEmitter = new EventEmitter(); - - /** - * Emits when the raw value of the slider changes. This is here primarily - * to facilitate the two-way binding for the `value` input. - * @docs-private - */ - @Output() readonly valueChange: EventEmitter<[number, number] | null> = new EventEmitter<[number, number] | null>(); + protected _value: [number, number] | null = null; /** The value to be used for display purposes. */ displayValue(focusIndex: 0 | 1): string | number { @@ -273,19 +104,6 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On return this.displayValue(1); } - /** set focus to the host element */ - focus(options?: FocusOptions) { - this._focusHostElement(options); - } - - /** blur the host element */ - blur() { - this._blurHostElement(); - } - - /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ - onTouched: () => any = () => {}; - /** The percentage of the slider that coincides with the value. */ get percent(): [number, number] { const min = this._clamp(this._percent[0], 0, this._percent[1]); @@ -294,28 +112,7 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } private _percent: [number, number] = [0, 1]; - _focusIndex: 0 | 1 = 0; - - /** - * Whether or not the thumb is sliding and what the user is using to slide it with. - * Used to determine if there should be a transition for the thumb and fill track. - */ - _isSliding: 'keyboard' | 'pointer' | null = null; - - /** - * Whether or not the slider is active (clicked or sliding). - */ - _isActive: boolean = false; - - /** - * Whether the axis of the slider is inverted. - * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). - */ - _shouldInvertAxis() { - // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to - // top. However from a y-axis standpoint this is inverted. - return this.vertical ? !this.invert : this.invert; - } + protected _focusIndex: 0 | 1 = 0; /** CSS styles for the track fill element. */ _getTrackFillStyles(): { [key: string]: string } { @@ -348,304 +145,11 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On }; } - /** The dimensions of the slider. */ - private _sliderDimensions: ClientRect | null = null; - - private _controlValueAccessorChangeFn: (value: any) => void = () => {}; - - /** Decimal places to round to, based on the step amount. */ - private _roundToDecimal: number; - - /** Subscription to the Directionality change EventEmitter. */ - private _dirChangeSubscription = Subscription.EMPTY; - /** The value of the slider when the slide start event fires. */ - private _valueOnSlideStart: [number, number] | null; - - /** Reference to the inner slider wrapper element. */ - @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; - /** - * Whether mouse events should be converted to a slider position by calculating their distance - * from the right or bottom edge of the slider as opposed to the top or left. - */ - _shouldInvertMouseCoords() { - const shouldInvertAxis = this._shouldInvertAxis(); - return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; - } - - /** The language direction for this slider element. */ - private _getDirection() { - return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; - } - - /** Keeps track of the last pointer event that was captured by the slider. */ - private _lastPointerEvent: MouseEvent | TouchEvent | null; - - /** Used to subscribe to global move and end events */ - protected _document: Document; - - /** - * Identifier used to attribute a touch event to a particular slider. - * Will be undefined if one of the following conditions is true: - * - The user isn't dragging using a touch device. - * - The browser doesn't support `Touch.identifier`. - * - Dragging hasn't started yet. - */ - private _touchId: number | undefined; - - constructor( - elementRef: ElementRef, - private _focusMonitor: FocusMonitor, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() private _dir: Directionality, - @Attribute('tabindex') tabIndex: string, - private _ngZone: NgZone, - @Inject(DOCUMENT) _document: any, - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string - ) { - super(elementRef); - this._document = _document; - this.tabIndex = parseInt(tabIndex) || 0; - - _ngZone.runOutsideAngular(() => { - const element = elementRef.nativeElement; - element.addEventListener('mousedown', this._pointerDown, activeEventOptions); - element.addEventListener('touchstart', this._pointerDown, activeEventOptions); - }); - } - - ngAfterViewInit() { - this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { - this._isActive = !!origin && origin !== 'keyboard'; - this._changeDetectorRef.detectChanges(); - }); - if (this._dir) { - this._dirChangeSubscription = this._dir.change.subscribe(() => { - this._changeDetectorRef.markForCheck(); - }); - } - } - - ngOnDestroy() { - const element = this._elementRef.nativeElement; - element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); - element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); - this._lastPointerEvent = null; - this._removeGlobalEvents(); - this._focusMonitor.stopMonitoring(this._elementRef); - this._dirChangeSubscription.unsubscribe(); - } - - _onMouseenter() { - if (this.disabled) { - return; - } - - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - } - - _onFocus() { - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - } - - _onBlur() { - this.onTouched(); - } - - _onKeydown(event: KeyboardEvent) { - if (this.disabled || hasModifierKey(event) || (this._isSliding && this._isSliding !== 'keyboard')) { - return; - } - - const oldValue = this.value; - - switch (event.keyCode) { - case PAGE_UP: - this._increment(10); - break; - case PAGE_DOWN: - this._increment(-10); - break; - case END: - this._increment(this.max - this.min); - break; - case HOME: - this._increment(this.max - this.min); - break; - case LEFT_ARROW: - this._increment(this._getDirection() == 'rtl' || this.invert ? 1 : -1); - break; - case UP_ARROW: - this._increment(1); - break; - case RIGHT_ARROW: - this._increment(this._getDirection() == 'rtl' || this.invert ? -1 : 1); - break; - case DOWN_ARROW: - this._increment(-1); - break; - default: - // Return if the key is not one that we explicitly handle to avoid calling preventDefault on - // it. - return; - } - - if (oldValue != this.value) { - this._emitInputEvent(); - this._emitChangeEvent(); - } - - this._isSliding = 'keyboard'; - event.preventDefault(); - } - - _onKeyup() { - if (this._isSliding === 'keyboard') { - this._isSliding = null; - } - } - - /** Called when the user has put their pointer down on the slider. */ - private _pointerDown = (event: TouchEvent | MouseEvent) => { - // Don't do anything if the slider is disabled or the - // user is using anything other than the main mouse button. - if (this.disabled || this._isSliding || (!isTouchEvent(event) && event.button !== 0)) { - return; - } - - this._ngZone.run(() => { - this._touchId = isTouchEvent(event) ? getTouchIdForSlider(event, this._elementRef.nativeElement) : undefined; - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - const oldValue = this.value; - this._isSliding = 'pointer'; - this._lastPointerEvent = event; - event.preventDefault(); - this._focusHostElement(); - this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. - this._bindGlobalEvents(event); - this._focusHostElement(); - this._updateValueFromPosition(pointerPosition, true); - this._valueOnSlideStart = oldValue; - - // Emit a change and input event if the value changed. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - }); - }; - - /** - * Called when the user has moved their pointer after - * starting to drag. Bound on the document level. - */ - private _pointerMove = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - // Prevent the slide from selecting anything else. - event.preventDefault(); - const oldValue = this.value; - this._lastPointerEvent = event; - this._updateValueFromPosition(pointerPosition, false); - - // Native range elements always emit `input` events when the value changed while sliding. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - } - }; - - /** Called when the user has lifted their pointer. Bound on the document level. */ - private _pointerUp = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - if ( - !isTouchEvent(event) || - typeof this._touchId !== 'number' || - // Note that we use `changedTouches`, rather than `touches` because it - // seems like in most cases `touches` is empty for `touchend` events. - findMatchingTouch(event.changedTouches, this._touchId) - ) { - event.preventDefault(); - this._removeGlobalEvents(); - this._isSliding = null; - this._touchId = undefined; - - if (this._valueOnSlideStart != this.value && !this.disabled) { - this._emitChangeEvent(); - } - - this._valueOnSlideStart = this._lastPointerEvent = null; - } - } - }; - - /** Called when the window has lost focus. */ - private _windowBlur = () => { - // If the window is blurred while dragging we need to stop dragging because the - // browser won't dispatch the `mouseup` and `touchend` events anymore. - if (this._lastPointerEvent) { - this._pointerUp(this._lastPointerEvent); - } - }; - - /** Use defaultView of injected document if available or fallback to global window reference */ - private _getWindow(): Window { - return this._document.defaultView || window; - } - - /** - * Binds our global move and end events. They're bound at the document level and only while - * dragging so that the user doesn't have to keep their pointer exactly over the slider - * as they're swiping across the screen. - */ - private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { - // Note that we bind the events to the `document`, because it allows us to capture - // drag cancel events where the user's pointer is outside the browser window. - const document = this._document; - const isTouch = isTouchEvent(triggerEvent); - const moveEventName = isTouch ? 'touchmove' : 'mousemove'; - const endEventName = isTouch ? 'touchend' : 'mouseup'; - document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); - document.addEventListener(endEventName, this._pointerUp, activeEventOptions); - - if (isTouch) { - document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); - } - - const window = this._getWindow(); - - if (typeof window !== 'undefined' && window) { - window.addEventListener('blur', this._windowBlur); - } - } - - /** Removes any global event listeners that we may have added. */ - private _removeGlobalEvents() { - const document = this._document; - document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); - document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); - document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); - document.removeEventListener('touchend', this._pointerUp, activeEventOptions); - document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); - - const window = this._getWindow(); - - if (typeof window !== 'undefined' && window) { - window.removeEventListener('blur', this._windowBlur); - } - } + protected _valueOnSlideStart: [number, number] | null; /** Increments the slider by the given number of steps (negative number decrements). */ - private _increment(numSteps: number) { + protected _increment(numSteps: number) { const rangeValue: [number, number] = [...this.value]; rangeValue[this._focusIndex] = this._focusIndex ? this.max : this.min; rangeValue[this._focusIndex] = this._clamp((this.value[this._focusIndex] || 0) + this.step * numSteps, ...rangeValue); @@ -653,7 +157,7 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On } /** Calculate the new value from the new physical location. The value will always be snapped. */ - private _updateValueFromPosition(pos: { x: number; y: number }, setFocusIndex: boolean) { + protected _updateValueFromPosition(pos: { x: number; y: number }, setFocusIndex = false) { if (!this._sliderDimensions) { return; } @@ -699,161 +203,4 @@ export class RangeSlider extends _SliderBase implements ControlValueAccessor, On rangeValue[this._focusIndex] = value; this.value = rangeValue; } - - /** Emits a change event if the current value is different from the last emitted value. */ - private _emitChangeEvent() { - this._controlValueAccessorChangeFn(this.value); - this.valueChange.emit(this.value); - this.change.emit(this._createChangeEvent()); - } - - /** Emits an input event when the current value is different from the last emitted value. */ - private _emitInputEvent() { - this.input.emit(this._createChangeEvent()); - } - - /** Creates a slider change object from the specified value. */ - private _createChangeEvent(value = this.value): RangeSliderChange { - let event = new RangeSliderChange(); - - event.source = this; - event.value = value; - - return event; - } - - /** Calculates the percentage of the slider that a value is. */ - private _calculatePercentage(value: number | null) { - return ((value || 0) - this.min) / (this.max - this.min); - } - - /** Calculates the value a percentage of the slider corresponds to. */ - private _calculateValue(percentage: number) { - return this.min + percentage * (this.max - this.min); - } - - /** Return a number between two numbers. */ - private _clamp(value: number, min = 0, max = 1) { - return Math.max(min, Math.min(value, max)); - } - - /** - * Get the bounding client rect of the slider track element. - * The track is used rather than the native element to ignore the extra space that the thumb can - * take up. - */ - private _getSliderDimensions() { - return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; - } - - /** - * Focuses the native element. - * Currently only used to allow a blur event to fire but will be used with keyboard input later. - */ - private _focusHostElement(options?: FocusOptions) { - this._elementRef.nativeElement.focus(options); - } - - /** Blurs the native element. */ - private _blurHostElement() { - this._elementRef.nativeElement.blur(); - } - - /** - * Sets the model value. Implemented as part of ControlValueAccessor. - * @param value - */ - writeValue(value: any) { - this.value = value; - } - - /** - * Registers a callback to be triggered when the value has changed. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnChange(fn: (value: any) => void) { - this._controlValueAccessorChangeFn = fn; - } - - /** - * Registers a callback to be triggered when the component is touched. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnTouched(fn: any) { - this.onTouched = fn; - } - - /** - * Sets whether the component should be disabled. - * Implemented as part of ControlValueAccessor. - * @param isDisabled - */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - static ngAcceptInputType_invert: BooleanInput; - static ngAcceptInputType_max: NumberInput; - static ngAcceptInputType_min: NumberInput; - static ngAcceptInputType_step: NumberInput; - static ngAcceptInputType_thumbLabel: BooleanInput; - static ngAcceptInputType_value: NumberInput; - static ngAcceptInputType_vertical: BooleanInput; - static ngAcceptInputType_disabled: BooleanInput; - static ngAcceptInputType_tabIndex: NumberInput; -} - -/** Returns whether an event is a touch event. */ -function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { - // This function is called for every pixel that the user has dragged so we need it to be - // as fast as possible. Since we only bind mouse events and touch events, we can assume - // that if the event's name starts with `t`, it's a touch event. - return event.type[0] === 't'; -} - -/** Gets the coordinates of a touch or mouse event relative to the viewport. */ -function getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { - let point: { clientX: number; clientY: number } | undefined; - - if (isTouchEvent(event)) { - // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. - // If that's the case, attribute the first touch to all active sliders. This should still cover - // the most common case while only breaking multi-touch. - if (typeof id === 'number') { - point = findMatchingTouch(event.touches, id) || findMatchingTouch(event.changedTouches, id); - } else { - // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. - point = event.touches[0] || event.changedTouches[0]; - } - } else { - point = event; - } - - return point ? { x: point.clientX, y: point.clientY } : undefined; -} - -/** Finds a `Touch` with a specific ID in a `TouchList`. */ -function findMatchingTouch(touches: TouchList, id: number): Touch | undefined { - for (let i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) { - return touches[i]; - } - } - - return undefined; -} - -/** Gets the unique ID of a touch that matches a specific slider. */ -function getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { - for (let i = 0; i < event.touches.length; i++) { - const target = event.touches[i].target as HTMLElement; - - if (sliderHost === target || sliderHost.contains(target)) { - return event.touches[i].identifier; - } - } - - return undefined; } diff --git a/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts index 4d33727b..2dc907c5 100644 --- a/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts +++ b/projects/supernova/src/lib/components/range-slider/renge-slider.stories.ts @@ -4,7 +4,7 @@ import { Component, Input } from '@angular/core'; import { RangeSliderModule } from './range-slider.module'; import { CheckboxModule } from '../checkbox'; -import { RangeSliderChange } from './range-slider.component'; +import { ISliderChange } from '../slider-base/slider-change.model'; @Component({ selector: 'range-slider-example', @@ -58,7 +58,7 @@ class RangeSliderComponent { public output: [number, number] | null; - change(e: RangeSliderChange): void { + change(e: ISliderChange<[number, number]>): void { this.output = e.value; } diff --git a/projects/supernova/src/lib/components/slider-base/slider-base.component.ts b/projects/supernova/src/lib/components/slider-base/slider-base.component.ts new file mode 100644 index 00000000..26f14038 --- /dev/null +++ b/projects/supernova/src/lib/components/slider-base/slider-base.component.ts @@ -0,0 +1,658 @@ +import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; +import { Directionality } from '@angular/cdk/bidi'; +import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion'; +import { + DOWN_ARROW, + END, + hasModifierKey, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import { + Attribute, + ChangeDetectorRef, + Component, + EventEmitter, + ElementRef, + HostBinding, + Inject, + Input, + NgZone, + OnInit, + Optional, + ViewChild, + OnDestroy, + AfterViewInit, + ViewEncapsulation, + ChangeDetectionStrategy, + Output, +} from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; + +import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; +import { normalizePassiveListenerOptions } from '@angular/cdk/platform'; +import { DOCUMENT } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { mixinDisabled, mixinTabIndex } from '../../mixins'; +import { SliderColor } from './slider-base.config'; +import { ISliderChange } from './slider-change.model'; + +const activeEventOptions = normalizePassiveListenerOptions({ passive: false }); + +const _SliderBase = mixinTabIndex( + mixinDisabled( + class { + constructor(public _elementRef: ElementRef) {} + } + ) +); + +@Component({ + host: { + '(focus)': '_onFocus()', + '(blur)': '_onBlur()', + '(keyup)': '_onKeyup()', + '(keydown)': '_onKeydown($event)', + '(mouseenter)': '_onMouseenter()', + + // On Safari starting to slide temporarily triggers text selection mode which + // show the wrong cursor. We prevent it by stopping the `selectstart` event. + '(selectstart)': '$event.preventDefault()', + class: 'sn-slider sn-focus-indicator', + role: 'slider', + '[tabIndex]': 'tabIndex', + '[attr.aria-disabled]': 'disabled', + '[attr.aria-valuemax]': 'max', + '[attr.aria-valuemin]': 'min', + '[attr.aria-valuenow]': 'value', + + '[attr.aria-valuetext]': 'valueText == null ? displayValue : valueText', + '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', + '[class.sn-slider-disabled]': 'disabled', + '[class.sn-slider-horizontal]': '!vertical', + '[class.sn-slider-axis-inverted]': '_shouldInvertAxis()', + '[class.sn-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', + '[class.sn-slider-vertical]': 'vertical', + '[class.sn-slider-sliding]': '_isSliding', + '[class.sn-slider-focused-start]': '_focusIndex === 0', + '[class.sn-slider-focused-end]': '_focusIndex === 1', + }, + template: ``, + inputs: ['disabled', 'tabIndex'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export abstract class SliderBase extends _SliderBase implements OnInit, ControlValueAccessor, OnDestroy, AfterViewInit { + protected abstract _value: T; + protected abstract min: number; + protected abstract max: number; + protected abstract _updateValueFromPosition(pos: { x: number; y: number }, setFocusIndex?: boolean): void; + protected abstract _increment(numSteps: number): void; + protected abstract _valueOnSlideStart: T | null; + abstract value: T; + /** Whether the slider is inverted. */ + @Input() + get invert(): boolean { + return this._invert; + } + set invert(value: boolean) { + this._invert = coerceBooleanProperty(value); + } + private _invert = false; + + /** The values at which the thumb will snap. */ + @Input() + get step(): number { + return this._step; + } + set step(v: number) { + this._step = coerceNumberProperty(v, this._step); + + if (this._step % 1 !== 0) { + this._roundToDecimal = this._step.toString().split('.').pop()!.length; + } + + // Since this could modify the label, we need to notify the change detection. + this._changeDetectorRef.markForCheck(); + } + private _step: number = 1; + + /** Whether or not to show the thumb label. */ + @Input() + get thumbLabel(): boolean { + return this._thumbLabel; + } + set thumbLabel(value: boolean) { + this._thumbLabel = coerceBooleanProperty(value); + } + private _thumbLabel: boolean = true; + + /** + * Function that will be used to format the value before it is displayed + * in the thumb label. Can be used to format very large number in order + * for them to fit into the slider thumb. + */ + @Input() displayWith: (value: number) => string | number; + + /** Text corresponding to the slider's value. Used primarily for improved accessibility. */ + @Input() valueText: string; + + /** Whether the slider is vertical. */ + @Input() + get vertical(): boolean { + return this._vertical; + } + set vertical(value: boolean) { + this._vertical = coerceBooleanProperty(value); + } + private _vertical = false; + + @Input() + get color(): SliderColor { + return this._color || 'primary'; + } + set color(newValue: SliderColor) { + this._color = newValue; + } + private _color: SliderColor; + + /** Event emitted when the slider value has changed. */ + @Output() readonly change: EventEmitter> = new EventEmitter>(); + + /** Event emitted when the slider thumb moves. */ + @Output() readonly input: EventEmitter> = new EventEmitter>(); + + /** + * Emits when the raw value of the slider changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + * @docs-private + */ + @Output() readonly valueChange: EventEmitter = new EventEmitter(); + + @HostBinding('class') get switchClasses() { + return { + [`sn-slider-${this.color}`]: this.color, + }; + } + + /** set focus to the host element */ + focus(options?: FocusOptions) { + this._focusHostElement(options); + } + + /** blur the host element */ + blur() { + this._blurHostElement(); + } + + /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ + onTouched: () => any = () => {}; + + /** + * Whether or not the thumb is sliding and what the user is using to slide it with. + * Used to determine if there should be a transition for the thumb and fill track. + */ + protected _isSliding: 'keyboard' | 'pointer' | null = null; + + /** + * Whether or not the slider is active (clicked or sliding). + */ + protected _isActive: boolean = false; + + /** + * Whether the axis of the slider is inverted. + * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). + */ + protected _shouldInvertAxis() { + // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to + // top. However from a y-axis standpoint this is inverted. + return this.vertical ? !this.invert : this.invert; + } + + /** The dimensions of the slider. */ + protected _sliderDimensions: ClientRect | null = null; + + private _controlValueAccessorChangeFn: (value: any) => void = () => {}; + + /** Decimal places to round to, based on the step amount. */ + protected _roundToDecimal: number; + + /** Subscription to the Directionality change EventEmitter. */ + private _dirChangeSubscription = Subscription.EMPTY; + + /** Reference to the inner slider wrapper element. */ + @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; + + ngAfterViewInit() { + this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { + this._isActive = !!origin && origin !== 'keyboard'; + this._changeDetectorRef.detectChanges(); + }); + if (this._dir) { + this._dirChangeSubscription = this._dir.change.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); + } + } + + ngOnDestroy() { + const element = this._elementRef.nativeElement; + element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); + element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); + this._lastPointerEvent = null; + this._removeGlobalEvents(); + this._focusMonitor.stopMonitoring(this._elementRef); + this._dirChangeSubscription.unsubscribe(); + } + + private _onMouseenter() { + if (this.disabled) { + return; + } + + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + } + + private _onFocus() { + // We save the dimensions of the slider here so we can use them to update the spacing of the + // ticks and determine where on the slider click and slide events happen. + this._sliderDimensions = this._getSliderDimensions(); + } + + private _onBlur() { + this.onTouched(); + } + + /** + * Whether mouse events should be converted to a slider position by calculating their distance + * from the right or bottom edge of the slider as opposed to the top or left. + */ + protected _shouldInvertMouseCoords() { + const shouldInvertAxis = this._shouldInvertAxis(); + return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; + } + + /** The language direction for this slider element. */ + protected _getDirection() { + return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; + } + + /** Keeps track of the last pointer event that was captured by the slider. */ + protected _lastPointerEvent: MouseEvent | TouchEvent | null; + + /** Used to subscribe to global move and end events */ + protected _document: Document; + + /** + * Identifier used to attribute a touch event to a particular slider. + * Will be undefined if one of the following conditions is true: + * - The user isn't dragging using a touch device. + * - The browser doesn't support `Touch.identifier`. + * - Dragging hasn't started yet. + */ + protected _touchId: number | undefined; + + /** Returns whether an event is a touch event. */ + static isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + // This function is called for every pixel that the user has dragged so we need it to be + // as fast as possible. Since we only bind mouse events and touch events, we can assume + // that if the event's name starts with `t`, it's a touch event. + return event.type[0] === 't'; + } + + /** Gets the coordinates of a touch or mouse event relative to the viewport. */ + static getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { + let point: { clientX: number; clientY: number } | undefined; + + if (this.isTouchEvent(event)) { + // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. + // If that's the case, attribute the first touch to all active sliders. This should still cover + // the most common case while only breaking multi-touch. + if (typeof id === 'number') { + point = this.findMatchingTouch(event.touches, id) || this.findMatchingTouch(event.changedTouches, id); + } else { + // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. + point = event.touches[0] || event.changedTouches[0]; + } + } else { + point = event; + } + + return point ? { x: point.clientX, y: point.clientY } : undefined; + } + + /** Finds a `Touch` with a specific ID in a `TouchList`. */ + static findMatchingTouch(touches: TouchList, id: number): Touch | undefined { + for (let i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) { + return touches[i]; + } + } + + return undefined; + } + + /** Gets the unique ID of a touch that matches a specific slider. */ + static getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { + for (let i = 0; i < event.touches.length; i++) { + const target = event.touches[i].target as HTMLElement; + + if (sliderHost === target || sliderHost.contains(target)) { + return event.touches[i].identifier; + } + } + + return undefined; + } + + constructor( + private elementRef: ElementRef, + private _focusMonitor: FocusMonitor, + protected _changeDetectorRef: ChangeDetectorRef, + @Optional() private _dir: Directionality, + @Attribute('tabindex') tabIndex: string, + protected _ngZone: NgZone, + @Inject(DOCUMENT) _document: any, + @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string + ) { + super(elementRef); + this._document = _document; + this.tabIndex = parseInt(tabIndex) || 0; + } + + ngOnInit(): void { + this._ngZone.runOutsideAngular(() => { + const element = this.elementRef.nativeElement; + element.addEventListener('mousedown', this._pointerDown, activeEventOptions); + element.addEventListener('touchstart', this._pointerDown, activeEventOptions); + }); + } + + private _onKeydown(event: KeyboardEvent) { + if (this.disabled || hasModifierKey(event) || (this._isSliding && this._isSliding !== 'keyboard')) { + return; + } + + const oldValue = this.value; + + switch (event.keyCode) { + case PAGE_UP: + this._increment(10); + break; + case PAGE_DOWN: + this._increment(-10); + break; + case END: + this._increment(this.max - this.min); + break; + case HOME: + this._increment(this.max - this.min); + break; + case LEFT_ARROW: + this._increment(this._getDirection() == 'rtl' || this.invert ? 1 : -1); + break; + case UP_ARROW: + this._increment(1); + break; + case RIGHT_ARROW: + this._increment(this._getDirection() == 'rtl' || this.invert ? -1 : 1); + break; + case DOWN_ARROW: + this._increment(-1); + break; + default: + // Return if the key is not one that we explicitly handle to avoid calling preventDefault on + // it. + return; + } + + if (oldValue != this.value) { + this._emitInputEvent(); + this._emitChangeEvent(); + } + + this._isSliding = 'keyboard'; + event.preventDefault(); + } + + private _onKeyup() { + if (this._isSliding === 'keyboard') { + this._isSliding = null; + } + } + + /** Called when the user has put their pointer down on the slider. */ + private _pointerDown = (event: TouchEvent | MouseEvent) => { + // Don't do anything if the slider is disabled or the + // user is using anything other than the main mouse button. + if (this.disabled || this._isSliding || (!SliderBase.isTouchEvent(event) && event.button !== 0)) { + return; + } + + this._ngZone.run(() => { + this._touchId = SliderBase.isTouchEvent(event) + ? SliderBase.getTouchIdForSlider(event, this._elementRef.nativeElement) + : undefined; + const pointerPosition = SliderBase.getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + const oldValue = this.value; + this._isSliding = 'pointer'; + this._lastPointerEvent = event; + event.preventDefault(); + this._focusHostElement(); + this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. + this._bindGlobalEvents(event); + this._focusHostElement(); + this._updateValueFromPosition(pointerPosition, true); + this._valueOnSlideStart = oldValue; + + // Emit a change and input event if the value changed. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + }); + }; + + /** + * Called when the user has moved their pointer after + * starting to drag. Bound on the document level. + */ + private _pointerMove = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + const pointerPosition = SliderBase.getPointerPositionOnPage(event, this._touchId); + + if (pointerPosition) { + // Prevent the slide from selecting anything else. + event.preventDefault(); + const oldValue = this.value; + this._lastPointerEvent = event; + this._updateValueFromPosition(pointerPosition); + + // Native range elements always emit `input` events when the value changed while sliding. + if (oldValue != this.value) { + this._emitInputEvent(); + } + } + } + }; + + /** Called when the user has lifted their pointer. Bound on the document level. */ + private _pointerUp = (event: TouchEvent | MouseEvent) => { + if (this._isSliding === 'pointer') { + if ( + !SliderBase.isTouchEvent(event) || + typeof this._touchId !== 'number' || + // Note that we use `changedTouches`, rather than `touches` because it + // seems like in most cases `touches` is empty for `touchend` events. + SliderBase.findMatchingTouch(event.changedTouches, this._touchId) + ) { + event.preventDefault(); + this._removeGlobalEvents(); + this._isSliding = null; + this._touchId = undefined; + + if (this._valueOnSlideStart != this.value && !this.disabled) { + this._emitChangeEvent(); + } + + this._valueOnSlideStart = this._lastPointerEvent = null; + } + } + }; + + /** Called when the window has lost focus. */ + private _windowBlur = () => { + // If the window is blurred while dragging we need to stop dragging because the + // browser won't dispatch the `mouseup` and `touchend` events anymore. + if (this._lastPointerEvent) { + this._pointerUp(this._lastPointerEvent); + } + }; + + /** Use defaultView of injected document if available or fallback to global window reference */ + private _getWindow(): Window { + return this._document.defaultView || window; + } + + /** + * Binds our global move and end events. They're bound at the document level and only while + * dragging so that the user doesn't have to keep their pointer exactly over the slider + * as they're swiping across the screen. + */ + private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { + // Note that we bind the events to the `document`, because it allows us to capture + // drag cancel events where the user's pointer is outside the browser window. + const document = this._document; + const isTouch = SliderBase.isTouchEvent(triggerEvent); + const moveEventName = isTouch ? 'touchmove' : 'mousemove'; + const endEventName = isTouch ? 'touchend' : 'mouseup'; + document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); + document.addEventListener(endEventName, this._pointerUp, activeEventOptions); + + if (isTouch) { + document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); + } + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.addEventListener('blur', this._windowBlur); + } + } + + /** Removes any global event listeners that we may have added. */ + private _removeGlobalEvents() { + const document = this._document; + document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); + document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); + document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); + document.removeEventListener('touchend', this._pointerUp, activeEventOptions); + document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); + + const window = this._getWindow(); + + if (typeof window !== 'undefined' && window) { + window.removeEventListener('blur', this._windowBlur); + } + } + + /** Emits a change event if the current value is different from the last emitted value. */ + private _emitChangeEvent() { + this._controlValueAccessorChangeFn(this.value); + this.valueChange.emit(this.value); + this.change.emit(this._createChangeEvent()); + } + + /** Emits an input event when the current value is different from the last emitted value. */ + private _emitInputEvent() { + this.input.emit(this._createChangeEvent()); + } + + /** Calculates the percentage of the slider that a value is. */ + protected _calculatePercentage(value: number | null) { + return ((value || 0) - this.min) / (this.max - this.min); + } + + /** Calculates the value a percentage of the slider corresponds to. */ + protected _calculateValue(percentage: number) { + return this.min + percentage * (this.max - this.min); + } + + /** Return a number between two numbers. */ + protected _clamp(value: number, min = 0, max = 1) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Get the bounding client rect of the slider track element. + * The track is used rather than the native element to ignore the extra space that the thumb can + * take up. + */ + private _getSliderDimensions() { + return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; + } + + /** + * Focuses the native element. + * Currently only used to allow a blur event to fire but will be used with keyboard input later. + */ + private _focusHostElement(options?: FocusOptions) { + this._elementRef.nativeElement.focus(options); + } + + /** Blurs the native element. */ + private _blurHostElement() { + this._elementRef.nativeElement.blur(); + } + + /** + * Sets the model value. Implemented as part of ControlValueAccessor. + * @param value + */ + writeValue(value: any) { + this.value = value; + } + + /** + * Registers a callback to be triggered when the value has changed. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnChange(fn: (value: any) => void) { + this._controlValueAccessorChangeFn = fn; + } + + /** + * Registers a callback to be triggered when the component is touched. + * Implemented as part of ControlValueAccessor. + * @param fn Callback to be registered. + */ + registerOnTouched(fn: any) { + this.onTouched = fn; + } + + /** + * Sets whether the component should be disabled. + * Implemented as part of ControlValueAccessor. + * @param isDisabled + */ + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + /** Creates a slider change object from the specified value. */ + protected _createChangeEvent(value = this.value): ISliderChange { + return { + source: this, + value, + }; + } +} diff --git a/projects/supernova/src/lib/components/range-slider/range-slider.config.ts b/projects/supernova/src/lib/components/slider-base/slider-base.config.ts similarity index 100% rename from projects/supernova/src/lib/components/range-slider/range-slider.config.ts rename to projects/supernova/src/lib/components/slider-base/slider-base.config.ts diff --git a/projects/supernova/src/lib/components/slider-base/slider-change.model.ts b/projects/supernova/src/lib/components/slider-base/slider-change.model.ts new file mode 100644 index 00000000..284315d1 --- /dev/null +++ b/projects/supernova/src/lib/components/slider-base/slider-change.model.ts @@ -0,0 +1,4 @@ +export interface ISliderChange { + source: any; + value: T; +} diff --git a/projects/supernova/src/lib/components/slider/slider.component.ts b/projects/supernova/src/lib/components/slider/slider.component.ts index 775b5812..b1359486 100644 --- a/projects/supernova/src/lib/components/slider/slider.component.ts +++ b/projects/supernova/src/lib/components/slider/slider.component.ts @@ -1,46 +1,8 @@ -import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y'; -import { Directionality } from '@angular/cdk/bidi'; -import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; -import { - DOWN_ARROW, - END, - HOME, - LEFT_ARROW, - PAGE_DOWN, - PAGE_UP, - RIGHT_ARROW, - UP_ARROW, - hasModifierKey, -} from '@angular/cdk/keycodes'; -import { - Attribute, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ElementRef, - EventEmitter, - forwardRef, - Inject, - Input, - OnDestroy, - Optional, - Output, - ViewChild, - ViewEncapsulation, - NgZone, - AfterViewInit, - HostBinding, -} from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; -import { normalizePassiveListenerOptions } from '@angular/cdk/platform'; -import { DOCUMENT } from '@angular/common'; -import { Subscription } from 'rxjs'; -import { mixinTabIndex, mixinDisabled } from '../../mixins'; -import { SliderColor } from './slider.config'; - -const activeEventOptions = normalizePassiveListenerOptions({ passive: false }); +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { Component, forwardRef, Input } from '@angular/core'; + +import { SliderBase } from '../slider-base/slider-base.component'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; /** The thumb gap size for a disabled slider. */ const DISABLED_THUMB_GAP = 7; @@ -51,83 +13,23 @@ const MIN_VALUE_NONACTIVE_THUMB_GAP = 7; /** The thumb gap size for an active slider at its minimum value. */ const MIN_VALUE_ACTIVE_THUMB_GAP = 8; -/** - * Provider Expression that allows mat-slider to register as a ControlValueAccessor. - * This allows it to support [(ngModel)] and [formControl]. - * @docs-private - */ export const SLIDER_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Slider), multi: true, }; -/** A simple change event emitted by the Slider component. */ -export class SliderChange { - source: Slider; - value: number | null; -} - -const _SliderBase = mixinTabIndex( - mixinDisabled( - class { - constructor(public _elementRef: ElementRef) {} - } - ) -); - /** * Allows users to select from a range of values by moving the slider thumb. It is similar in * behavior to the native `` element. */ @Component({ + providers: [SLIDER_VALUE_ACCESSOR], selector: 'sn-slider', exportAs: 'snSlider', - providers: [SLIDER_VALUE_ACCESSOR], - host: { - '(focus)': '_onFocus()', - '(blur)': '_onBlur()', - '(keyup)': '_onKeyup()', - '(keydown)': '_onKeydown($event)', - '(mouseenter)': '_onMouseenter()', - - // On Safari starting to slide temporarily triggers text selection mode which - // show the wrong cursor. We prevent it by stopping the `selectstart` event. - '(selectstart)': '$event.preventDefault()', - class: 'sn-slider sn-focus-indicator', - role: 'slider', - '[tabIndex]': 'tabIndex', - '[attr.aria-disabled]': 'disabled', - '[attr.aria-valuemax]': 'max', - '[attr.aria-valuemin]': 'min', - '[attr.aria-valuenow]': 'value', - - '[attr.aria-valuetext]': 'valueText == null ? displayValue : valueText', - '[attr.aria-orientation]': 'vertical ? "vertical" : "horizontal"', - '[class.sn-slider-disabled]': 'disabled', - '[class.sn-slider-horizontal]': '!vertical', - '[class.sn-slider-axis-inverted]': '_shouldInvertAxis()', - '[class.sn-slider-invert-mouse-coords]': '_shouldInvertMouseCoords()', - '[class.sn-slider-vertical]': 'vertical', - '[class.sn-slider-sliding]': '_isSliding', - '[class.sn-slider-min-value]': '_isMinValue()', - }, templateUrl: 'slider.component.html', - inputs: ['disabled', 'tabIndex'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Slider extends _SliderBase implements ControlValueAccessor, OnDestroy, AfterViewInit { - /** Whether the slider is inverted. */ - @Input() - get invert(): boolean { - return this._invert; - } - set invert(value: boolean) { - this._invert = coerceBooleanProperty(value); - } - private _invert = false; - +export class Slider extends SliderBase { /** The maximum value that the slider can have. */ @Input() get max(): number { @@ -140,7 +42,7 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr // Since this also modifies the percentage, we need to let the change detection know. this._changeDetectorRef.markForCheck(); } - private _max: number = 100; + protected _max: number = 100; /** The minimum value that the slider can have. */ @Input() @@ -154,39 +56,12 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr // Since this also modifies the percentage, we need to let the change detection know. this._changeDetectorRef.markForCheck(); } - private _min: number = 0; - - /** The values at which the thumb will snap. */ - @Input() - get step(): number { - return this._step; - } - set step(v: number) { - this._step = coerceNumberProperty(v, this._step); - - if (this._step % 1 !== 0) { - this._roundToDecimal = this._step.toString().split('.').pop()!.length; - } - - // Since this could modify the label, we need to notify the change detection. - this._changeDetectorRef.markForCheck(); - } - private _step: number = 1; - - /** Whether or not to show the thumb label. */ - @Input() - get thumbLabel(): boolean { - return this._thumbLabel; - } - set thumbLabel(value: boolean) { - this._thumbLabel = coerceBooleanProperty(value); - } - private _thumbLabel: boolean = true; + protected _min: number = 0; /** Value of the slider. */ @Input() get value(): number { - // If the value needs to be read and it is still uninitialized, initialize it to the min. + // If the value needs to be read, and it is still uninitialized, initialize it to the min. if (this._value === null) { this.value = this._min; } @@ -209,55 +84,7 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr this._changeDetectorRef.markForCheck(); } } - private _value: number | null = null; - - /** - * Function that will be used to format the value before it is displayed - * in the thumb label. Can be used to format very large number in order - * for them to fit into the slider thumb. - */ - @Input() displayWith: (value: number) => string | number; - - /** Text corresponding to the slider's value. Used primarily for improved accessibility. */ - @Input() valueText: string; - - /** Whether the slider is vertical. */ - @Input() - get vertical(): boolean { - return this._vertical; - } - set vertical(value: boolean) { - this._vertical = coerceBooleanProperty(value); - } - private _vertical = false; - - @Input() - get color(): SliderColor { - return this._color || 'primary'; - } - set color(newValue: SliderColor) { - this._color = newValue; - } - private _color: SliderColor; - - @HostBinding('class') get switchClasses() { - return { - [`sn-slider-${this.color}`]: this.color, - }; - } - - /** Event emitted when the slider value has changed. */ - @Output() readonly change: EventEmitter = new EventEmitter(); - - /** Event emitted when the slider thumb moves. */ - @Output() readonly input: EventEmitter = new EventEmitter(); - - /** - * Emits when the raw value of the slider changes. This is here primarily - * to facilitate the two-way binding for the `value` input. - * @docs-private - */ - @Output() readonly valueChange: EventEmitter = new EventEmitter(); + protected _value: number | null = null; /** The value to be used for display purposes. */ get displayValue(): string | number { @@ -277,46 +104,12 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr return this.value || 0; } - /** set focus to the host element */ - focus(options?: FocusOptions) { - this._focusHostElement(options); - } - - /** blur the host element */ - blur() { - this._blurHostElement(); - } - - /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ - onTouched: () => any = () => {}; - /** The percentage of the slider that coincides with the value. */ get percent(): number { return this._clamp(this._percent); } private _percent: number = 0; - /** - * Whether or not the thumb is sliding and what the user is using to slide it with. - * Used to determine if there should be a transition for the thumb and fill track. - */ - _isSliding: 'keyboard' | 'pointer' | null = null; - - /** - * Whether or not the slider is active (clicked or sliding). - */ - _isActive: boolean = false; - - /** - * Whether the axis of the slider is inverted. - * (i.e. whether moving the thumb in the positive x or y direction decreases the slider's value). - */ - _shouldInvertAxis() { - // Standard non-inverted mode for a vertical slider should be dragging the thumb from bottom to - // top. However from a y-axis standpoint this is inverted. - return this.vertical ? !this.invert : this.invert; - } - /** Whether the slider is at its minimum value. */ _isMinValue() { return this.percent === 0; @@ -378,309 +171,16 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr }; } - /** The dimensions of the slider. */ - private _sliderDimensions: ClientRect | null = null; - - private _controlValueAccessorChangeFn: (value: any) => void = () => {}; - - /** Decimal places to round to, based on the step amount. */ - private _roundToDecimal: number; - - /** Subscription to the Directionality change EventEmitter. */ - private _dirChangeSubscription = Subscription.EMPTY; - /** The value of the slider when the slide start event fires. */ - private _valueOnSlideStart: number | null; - - /** Reference to the inner slider wrapper element. */ - @ViewChild('sliderWrapper') private _sliderWrapper: ElementRef; - /** - * Whether mouse events should be converted to a slider position by calculating their distance - * from the right or bottom edge of the slider as opposed to the top or left. - */ - _shouldInvertMouseCoords() { - const shouldInvertAxis = this._shouldInvertAxis(); - return this._getDirection() == 'rtl' && !this.vertical ? !shouldInvertAxis : shouldInvertAxis; - } - - /** The language direction for this slider element. */ - private _getDirection() { - return this._dir && this._dir.value == 'rtl' ? 'rtl' : 'ltr'; - } - - /** Keeps track of the last pointer event that was captured by the slider. */ - private _lastPointerEvent: MouseEvent | TouchEvent | null; - - /** Used to subscribe to global move and end events */ - protected _document: Document; - - /** - * Identifier used to attribute a touch event to a particular slider. - * Will be undefined if one of the following conditions is true: - * - The user isn't dragging using a touch device. - * - The browser doesn't support `Touch.identifier`. - * - Dragging hasn't started yet. - */ - private _touchId: number | undefined; - - constructor( - elementRef: ElementRef, - private _focusMonitor: FocusMonitor, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() private _dir: Directionality, - @Attribute('tabindex') tabIndex: string, - private _ngZone: NgZone, - @Inject(DOCUMENT) _document: any, - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string - ) { - super(elementRef); - this._document = _document; - this.tabIndex = parseInt(tabIndex) || 0; - - _ngZone.runOutsideAngular(() => { - const element = elementRef.nativeElement; - element.addEventListener('mousedown', this._pointerDown, activeEventOptions); - element.addEventListener('touchstart', this._pointerDown, activeEventOptions); - }); - } - - ngAfterViewInit() { - this._focusMonitor.monitor(this._elementRef, true).subscribe((origin: FocusOrigin) => { - this._isActive = !!origin && origin !== 'keyboard'; - this._changeDetectorRef.detectChanges(); - }); - if (this._dir) { - this._dirChangeSubscription = this._dir.change.subscribe(() => { - this._changeDetectorRef.markForCheck(); - }); - } - } - - ngOnDestroy() { - const element = this._elementRef.nativeElement; - element.removeEventListener('mousedown', this._pointerDown, activeEventOptions); - element.removeEventListener('touchstart', this._pointerDown, activeEventOptions); - this._lastPointerEvent = null; - this._removeGlobalEvents(); - this._focusMonitor.stopMonitoring(this._elementRef); - this._dirChangeSubscription.unsubscribe(); - } - - _onMouseenter() { - if (this.disabled) { - return; - } - - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - } - - _onFocus() { - // We save the dimensions of the slider here so we can use them to update the spacing of the - // ticks and determine where on the slider click and slide events happen. - this._sliderDimensions = this._getSliderDimensions(); - } - - _onBlur() { - this.onTouched(); - } - - _onKeydown(event: KeyboardEvent) { - if (this.disabled || hasModifierKey(event) || (this._isSliding && this._isSliding !== 'keyboard')) { - return; - } - - const oldValue = this.value; - - switch (event.keyCode) { - case PAGE_UP: - this._increment(10); - break; - case PAGE_DOWN: - this._increment(-10); - break; - case END: - this.value = this.max; - break; - case HOME: - this.value = this.min; - break; - case LEFT_ARROW: - this._increment(this._getDirection() == 'rtl' || this.invert ? 1 : -1); - break; - case UP_ARROW: - this._increment(1); - break; - case RIGHT_ARROW: - this._increment(this._getDirection() == 'rtl' || this.invert ? -1 : 1); - break; - case DOWN_ARROW: - this._increment(-1); - break; - default: - // Return if the key is not one that we explicitly handle to avoid calling preventDefault on - // it. - return; - } - - if (oldValue != this.value) { - this._emitInputEvent(); - this._emitChangeEvent(); - } - - this._isSliding = 'keyboard'; - event.preventDefault(); - } - - _onKeyup() { - if (this._isSliding === 'keyboard') { - this._isSliding = null; - } - } - - /** Called when the user has put their pointer down on the slider. */ - private _pointerDown = (event: TouchEvent | MouseEvent) => { - // Don't do anything if the slider is disabled or the - // user is using anything other than the main mouse button. - if (this.disabled || this._isSliding || (!isTouchEvent(event) && event.button !== 0)) { - return; - } - - this._ngZone.run(() => { - this._touchId = isTouchEvent(event) ? getTouchIdForSlider(event, this._elementRef.nativeElement) : undefined; - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - const oldValue = this.value; - this._isSliding = 'pointer'; - this._lastPointerEvent = event; - event.preventDefault(); - this._focusHostElement(); - this._onMouseenter(); // Simulate mouseenter in case this is a mobile device. - this._bindGlobalEvents(event); - this._focusHostElement(); - this._updateValueFromPosition(pointerPosition); - this._valueOnSlideStart = oldValue; - - // Emit a change and input event if the value changed. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - }); - }; - - /** - * Called when the user has moved their pointer after - * starting to drag. Bound on the document level. - */ - private _pointerMove = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - const pointerPosition = getPointerPositionOnPage(event, this._touchId); - - if (pointerPosition) { - // Prevent the slide from selecting anything else. - event.preventDefault(); - const oldValue = this.value; - this._lastPointerEvent = event; - this._updateValueFromPosition(pointerPosition); - - // Native range elements always emit `input` events when the value changed while sliding. - if (oldValue != this.value) { - this._emitInputEvent(); - } - } - } - }; - - /** Called when the user has lifted their pointer. Bound on the document level. */ - private _pointerUp = (event: TouchEvent | MouseEvent) => { - if (this._isSliding === 'pointer') { - if ( - !isTouchEvent(event) || - typeof this._touchId !== 'number' || - // Note that we use `changedTouches`, rather than `touches` because it - // seems like in most cases `touches` is empty for `touchend` events. - findMatchingTouch(event.changedTouches, this._touchId) - ) { - event.preventDefault(); - this._removeGlobalEvents(); - this._isSliding = null; - this._touchId = undefined; - - if (this._valueOnSlideStart != this.value && !this.disabled) { - this._emitChangeEvent(); - } - - this._valueOnSlideStart = this._lastPointerEvent = null; - } - } - }; - - /** Called when the window has lost focus. */ - private _windowBlur = () => { - // If the window is blurred while dragging we need to stop dragging because the - // browser won't dispatch the `mouseup` and `touchend` events anymore. - if (this._lastPointerEvent) { - this._pointerUp(this._lastPointerEvent); - } - }; - - /** Use defaultView of injected document if available or fallback to global window reference */ - private _getWindow(): Window { - return this._document.defaultView || window; - } - - /** - * Binds our global move and end events. They're bound at the document level and only while - * dragging so that the user doesn't have to keep their pointer exactly over the slider - * as they're swiping across the screen. - */ - private _bindGlobalEvents(triggerEvent: TouchEvent | MouseEvent) { - // Note that we bind the events to the `document`, because it allows us to capture - // drag cancel events where the user's pointer is outside the browser window. - const document = this._document; - const isTouch = isTouchEvent(triggerEvent); - const moveEventName = isTouch ? 'touchmove' : 'mousemove'; - const endEventName = isTouch ? 'touchend' : 'mouseup'; - document.addEventListener(moveEventName, this._pointerMove, activeEventOptions); - document.addEventListener(endEventName, this._pointerUp, activeEventOptions); - - if (isTouch) { - document.addEventListener('touchcancel', this._pointerUp, activeEventOptions); - } - - const window = this._getWindow(); - - if (typeof window !== 'undefined' && window) { - window.addEventListener('blur', this._windowBlur); - } - } - - /** Removes any global event listeners that we may have added. */ - private _removeGlobalEvents() { - const document = this._document; - document.removeEventListener('mousemove', this._pointerMove, activeEventOptions); - document.removeEventListener('mouseup', this._pointerUp, activeEventOptions); - document.removeEventListener('touchmove', this._pointerMove, activeEventOptions); - document.removeEventListener('touchend', this._pointerUp, activeEventOptions); - document.removeEventListener('touchcancel', this._pointerUp, activeEventOptions); - - const window = this._getWindow(); - - if (typeof window !== 'undefined' && window) { - window.removeEventListener('blur', this._windowBlur); - } - } + protected _valueOnSlideStart: number | null; /** Increments the slider by the given number of steps (negative number decrements). */ - private _increment(numSteps: number) { + protected _increment(numSteps: number) { this.value = this._clamp((this.value || 0) + this.step * numSteps, this.min, this.max); } /** Calculate the new value from the new physical location. The value will always be snapped. */ - private _updateValueFromPosition(pos: { x: number; y: number }) { + protected _updateValueFromPosition(pos: { x: number; y: number }) { if (!this._sliderDimensions) { return; } @@ -714,161 +214,4 @@ export class Slider extends _SliderBase implements ControlValueAccessor, OnDestr this.value = this._clamp(closestValue, this.min, this.max); } } - - /** Emits a change event if the current value is different from the last emitted value. */ - private _emitChangeEvent() { - this._controlValueAccessorChangeFn(this.value); - this.valueChange.emit(this.value); - this.change.emit(this._createChangeEvent()); - } - - /** Emits an input event when the current value is different from the last emitted value. */ - private _emitInputEvent() { - this.input.emit(this._createChangeEvent()); - } - - /** Creates a slider change object from the specified value. */ - private _createChangeEvent(value = this.value): SliderChange { - let event = new SliderChange(); - - event.source = this; - event.value = value; - - return event; - } - - /** Calculates the percentage of the slider that a value is. */ - private _calculatePercentage(value: number | null) { - return ((value || 0) - this.min) / (this.max - this.min); - } - - /** Calculates the value a percentage of the slider corresponds to. */ - private _calculateValue(percentage: number) { - return this.min + percentage * (this.max - this.min); - } - - /** Return a number between two numbers. */ - private _clamp(value: number, min = 0, max = 1) { - return Math.max(min, Math.min(value, max)); - } - - /** - * Get the bounding client rect of the slider track element. - * The track is used rather than the native element to ignore the extra space that the thumb can - * take up. - */ - private _getSliderDimensions() { - return this._sliderWrapper ? this._sliderWrapper.nativeElement.getBoundingClientRect() : null; - } - - /** - * Focuses the native element. - * Currently only used to allow a blur event to fire but will be used with keyboard input later. - */ - private _focusHostElement(options?: FocusOptions) { - this._elementRef.nativeElement.focus(options); - } - - /** Blurs the native element. */ - private _blurHostElement() { - this._elementRef.nativeElement.blur(); - } - - /** - * Sets the model value. Implemented as part of ControlValueAccessor. - * @param value - */ - writeValue(value: any) { - this.value = value; - } - - /** - * Registers a callback to be triggered when the value has changed. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnChange(fn: (value: any) => void) { - this._controlValueAccessorChangeFn = fn; - } - - /** - * Registers a callback to be triggered when the component is touched. - * Implemented as part of ControlValueAccessor. - * @param fn Callback to be registered. - */ - registerOnTouched(fn: any) { - this.onTouched = fn; - } - - /** - * Sets whether the component should be disabled. - * Implemented as part of ControlValueAccessor. - * @param isDisabled - */ - setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; - } - - static ngAcceptInputType_invert: BooleanInput; - static ngAcceptInputType_max: NumberInput; - static ngAcceptInputType_min: NumberInput; - static ngAcceptInputType_step: NumberInput; - static ngAcceptInputType_thumbLabel: BooleanInput; - static ngAcceptInputType_value: NumberInput; - static ngAcceptInputType_vertical: BooleanInput; - static ngAcceptInputType_disabled: BooleanInput; - static ngAcceptInputType_tabIndex: NumberInput; -} - -/** Returns whether an event is a touch event. */ -function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { - // This function is called for every pixel that the user has dragged so we need it to be - // as fast as possible. Since we only bind mouse events and touch events, we can assume - // that if the event's name starts with `t`, it's a touch event. - return event.type[0] === 't'; -} - -/** Gets the coordinates of a touch or mouse event relative to the viewport. */ -function getPointerPositionOnPage(event: MouseEvent | TouchEvent, id: number | undefined) { - let point: { clientX: number; clientY: number } | undefined; - - if (isTouchEvent(event)) { - // The `identifier` could be undefined if the browser doesn't support `TouchEvent.identifier`. - // If that's the case, attribute the first touch to all active sliders. This should still cover - // the most common case while only breaking multi-touch. - if (typeof id === 'number') { - point = findMatchingTouch(event.touches, id) || findMatchingTouch(event.changedTouches, id); - } else { - // `touches` will be empty for start/end events so we have to fall back to `changedTouches`. - point = event.touches[0] || event.changedTouches[0]; - } - } else { - point = event; - } - - return point ? { x: point.clientX, y: point.clientY } : undefined; -} - -/** Finds a `Touch` with a specific ID in a `TouchList`. */ -function findMatchingTouch(touches: TouchList, id: number): Touch | undefined { - for (let i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) { - return touches[i]; - } - } - - return undefined; -} - -/** Gets the unique ID of a touch that matches a specific slider. */ -function getTouchIdForSlider(event: TouchEvent, sliderHost: HTMLElement): number | undefined { - for (let i = 0; i < event.touches.length; i++) { - const target = event.touches[i].target as HTMLElement; - - if (sliderHost === target || sliderHost.contains(target)) { - return event.touches[i].identifier; - } - } - - return undefined; } diff --git a/projects/supernova/src/lib/components/slider/slider.config.ts b/projects/supernova/src/lib/components/slider/slider.config.ts deleted file mode 100644 index 5c6d9104..00000000 --- a/projects/supernova/src/lib/components/slider/slider.config.ts +++ /dev/null @@ -1,2 +0,0 @@ -type CustomSliderColor = string; -export type SliderColor = 'primary' | CustomSliderColor; diff --git a/projects/supernova/src/lib/components/slider/slider.stories.ts b/projects/supernova/src/lib/components/slider/slider.stories.ts index ed02ce3e..8dd6f120 100644 --- a/projects/supernova/src/lib/components/slider/slider.stories.ts +++ b/projects/supernova/src/lib/components/slider/slider.stories.ts @@ -4,7 +4,7 @@ import { Component, Input } from '@angular/core'; import { SliderModule } from './slider.module'; import { CheckboxModule } from '../checkbox'; -import { SliderChange } from './slider.component'; +import { ISliderChange } from '../slider-base/slider-change.model'; @Component({ selector: 'slider-example', @@ -60,7 +60,7 @@ class SliderComponent { public output: number | null; - change(e: SliderChange): void { + change(e: ISliderChange): void { this.output = e.value; }