import {Component, Optional, Input, Output, EventEmitter, ViewChild, ViewChildren, QueryList, Renderer, ElementRef, Provider, Inject, forwardRef, ViewEncapsulation} from '@angular/core';
import {NG_VALUE_ACCESSOR} from '@angular/common';
import {Form} from '../../util/form';
import {isTrueProperty, isNumber, isString, isPresent, clamp} from '../../util/util';
import {Item} from '../item/item';
import {pointerCoord} from '../../util/dom';
const RANGE_VALUE_ACCESSOR = new Provider(
NG_VALUE_ACCESSOR, {useExisting: forwardRef(() => Range), multi: true});
@Component({
selector: '.range-knob-handle',
template:
'
{{_val}}
' +
'',
host: {
'[class.range-knob-pressed]': 'pressed',
'[style.left]': '_x',
'[style.top]': '_y',
'[style.transform]': '_trns',
'[attr.aria-valuenow]': '_val',
'[attr.aria-valuemin]': 'range.min',
'[attr.aria-valuemax]': 'range.max',
'role': 'slider',
'tabindex': '0'
}
})
export class RangeKnob {
private _ratio: number;
private _val: number;
private _x: string;
pressed: boolean;
@Input() upper: boolean;
constructor(@Inject(forwardRef(() => Range)) private range: Range) {}
get ratio(): number {
return this._ratio;
}
set ratio(ratio: number) {
this._ratio = clamp(0, ratio, 1);
this._val = this.range.ratioToValue(this._ratio);
if (this.range.snaps) {
this._ratio = this.range.valueToRatio(this._val);
}
}
get value(): number {
return this._val;
}
set value(val: number) {
if (isString(val)) {
val = Math.round(val);
}
if (isNumber(val) && !isNaN(val)) {
this._ratio = this.range.valueToRatio(val);
this._val = this.range.ratioToValue(this._ratio);
}
}
position() {
this._x = `${this._ratio * 100}%`;
}
ngOnInit() {
if (isPresent(this.range.value)) {
// we already have a value
if (this.range.dualKnobs) {
// we have a value and there are two knobs
if (this.upper) {
// this is the upper knob
this.value = this.range.value.upper;
} else {
// this is the lower knob
this.value = this.range.value.lower;
}
} else {
// we have a value and there is only one knob
this.value = this.range.value;
}
} else {
// we do not have a value so set defaults
this.ratio = ((this.range.dualKnobs && this.upper) ? 1 : 0);
}
this.position();
}
}
/**
* @name Range
*
* @description
*/
@Component({
selector: 'ion-range',
template:
'' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
',
host: {
'[class.range-disabled]': '_disabled',
'[class.range-pressed]': '_pressed',
},
directives: [RangeKnob],
providers: [RANGE_VALUE_ACCESSOR],
encapsulation: ViewEncapsulation.None,
})
export class Range {
private _dual: boolean = false;
private _pin: boolean;
private _disabled: boolean = false;
private _pressed: boolean;
private _labelId: string;
private _fn: Function;
private _active: RangeKnob;
private _start: Coordinates = null;
private _rect: ClientRect;
private _ticks: any[];
private _barL: string;
private _barR: string;
private _min: number = 0;
private _max: number = 100;
private _step: number = 1;
private _snaps: boolean = false;
private _removes: Function[] = [];
private _mouseRemove: Function;
value: any;
@ViewChild('bar') private _bar: ElementRef;
@ViewChild('slider') private _slider: ElementRef;
@ViewChildren(RangeKnob) private _knobs: QueryList;
/**
* @private
*/
id: string;
/**
* @input {number} Minimum integer value of the range. Defaults to `0`.
*/
@Input()
get min(): number {
return this._min;
}
set min(val: number) {
val = Math.round(val);
if (!isNaN(val)) {
this._min = val;
}
}
/**
* @input {number} Maximum integer value of the range. Defaults to `100`.
*/
@Input()
get max(): number {
return this._max;
}
set max(val: number) {
val = Math.round(val);
if (!isNaN(val)) {
this._max = val;
}
}
/**
* @input {number} Specifies the value granularity. Defaults to `1`.
*/
@Input()
get step(): number {
return this._step;
}
set step(val: number) {
val = Math.round(val);
if (!isNaN(val) && val > 0) {
this._step = val;
}
}
/**
* @input {number} If true, the knob snaps to tick marks evenly spaced based on the step property value. Defaults to `false`.
*/
@Input()
get snaps(): boolean {
return this._snaps;
}
set snaps(val: boolean) {
this._snaps = isTrueProperty(val);
}
/**
* @input {number} If true, a pin with integer value is shown when the knob is pressed. Defaults to `false`.
*/
@Input()
get pin(): boolean {
return this._pin;
}
set pin(val: boolean) {
this._pin = isTrueProperty(val);
}
/**
* @input {boolean} Show two knobs. Defaults to `false`.
*/
@Input()
get dualKnobs(): boolean {
return this._dual;
}
set dualKnobs(val: boolean) {
this._dual = isTrueProperty(val);
}
/**
* @output {Range} Expression to evaluate when the range value changes.
*/
@Output() rangeChange: EventEmitter = new EventEmitter();
constructor(
private _form: Form,
@Optional() private _item: Item,
private _renderer: Renderer
) {
_form.register(this);
if (_item) {
this.id = 'rng-' + _item.registerInput('range');
this._labelId = 'lbl-' + _item.id;
_item.setCssClass('item-range', true);
}
}
/**
* @private
*/
ngAfterViewInit() {
let barL = '';
let barR = '';
let firstRatio = this._knobs.first.ratio;
if (this._dual) {
let lastRatio = this._knobs.last.ratio;
barL = `${(Math.min(firstRatio, lastRatio) * 100)}%`;
barR = `${100 - (Math.max(firstRatio, lastRatio) * 100)}%`;
} else {
barR = `${100 - (firstRatio * 100)}%`;
}
this._renderer.setElementStyle(this._bar.nativeElement, 'left', barL);
this._renderer.setElementStyle(this._bar.nativeElement, 'right', barR);
this.createTicks();
// add touchstart/mousedown listeners
this._renderer.listen(this._slider.nativeElement, 'touchstart', this.pointerDown.bind(this));
this._mouseRemove = this._renderer.listen(this._slider.nativeElement, 'mousedown', this.pointerDown.bind(this));
}
/**
* @private
*/
pointerDown(ev: UIEvent) {
console.debug(`range, ${ev.type}`);
// prevent default so scrolling does not happen
ev.preventDefault();
ev.stopPropagation();
if (ev.type === 'touchstart') {
// if this was a touchstart, then let's remove the mousedown
this._mouseRemove && this._mouseRemove();
}
// get the start coordinates
this._start = pointerCoord(ev);
// get the full dimensions of the slider element
let rect: ClientRect = this._rect = this._slider.nativeElement.getBoundingClientRect();
// figure out the offset
// the start of the pointer could actually
// have been left or right of the slider bar
if (this._start.x < rect.left) {
rect.xOffset = (this._start.x - rect.left);
} else if (this._start.x > rect.right) {
rect.xOffset = (this._start.x - rect.right);
} else {
rect.xOffset = 0;
}
// figure out which knob we're interacting with
this.setActiveKnob(this._start, rect);
// update the ratio for the active knob
this.updateKnob(this._start, rect);
// ensure past listeners have been removed
this.clearListeners();
// update the active knob's position
this._active.position();
this._pressed = this._active.pressed = true;
// add a move listener depending on touch/mouse
let renderer = this._renderer;
let removes = this._removes;
if (ev.type === 'touchstart') {
removes.push(renderer.listen(this._slider.nativeElement, 'touchmove', this.pointerMove.bind(this)));
removes.push(renderer.listen(this._slider.nativeElement, 'touchend', this.pointerUp.bind(this)));
} else {
removes.push(renderer.listenGlobal('body', 'mousemove', this.pointerMove.bind(this)));
removes.push(renderer.listenGlobal('body', 'mouseup', this.pointerUp.bind(this)));
}
}
/**
* @private
*/
pointerMove(ev: UIEvent) {
console.debug(`range, ${ev.type}`);
// prevent default so scrolling does not happen
ev.preventDefault();
ev.stopPropagation();
if (this._start !== null && this._active !== null) {
// only use pointer move if it's a valid pointer
// and we already have start coordinates
// update the ratio for the active knob
this.updateKnob(pointerCoord(ev), this._rect);
// update the active knob's position
this._active.position();
this._pressed = this._active.pressed = true;
} else {
// ensure listeners have been removed
this.clearListeners();
}
}
/**
* @private
*/
pointerUp(ev: UIEvent) {
console.debug(`range, ${ev.type}`);
// prevent default so scrolling does not happen
ev.preventDefault();
ev.stopPropagation();
// update the ratio for the active knob
this.updateKnob(pointerCoord(ev), this._rect);
// update the active knob's position
this._active.position();
// clear the start coordinates and active knob
this._start = this._active = null;
// ensure listeners have been removed
this.clearListeners();
}
/**
* @private
*/
clearListeners() {
this._pressed = this._knobs.first.pressed = this._knobs.last.pressed = false;
for (var i = 0; i < this._removes.length; i++) {
this._removes[i]();
}
this._removes.length = 0;
}
/**
* @private
*/
setActiveKnob(current: Coordinates, rect: ClientRect) {
// figure out which knob is the closest one to the pointer
let ratio = (current.x - rect.left) / (rect.width);
if (this._dual && Math.abs(ratio - this._knobs.first.ratio) > Math.abs(ratio - this._knobs.last.ratio)) {
this._active = this._knobs.last;
} else {
this._active = this._knobs.first;
}
}
/**
* @private
*/
updateKnob(current: Coordinates, rect: ClientRect) {
// figure out where the pointer is currently at
// update the knob being interacted with
if (this._active) {
let oldVal = this._active.value;
this._active.ratio = (current.x - rect.left) / (rect.width);
let newVal = this._active.value;
if (oldVal !== newVal) {
// value has been updated
if (this._dual) {
this.value = {
lower: Math.min(this._knobs.first.value, this._knobs.last.value),
upper: Math.max(this._knobs.first.value, this._knobs.last.value),
};
} else {
this.value = newVal;
}
this.onChange(this.value);
}
this.updateBar();
}
}
/**
* @private
*/
updateBar() {
let firstRatio = this._knobs.first.ratio;
if (this._dual) {
let lastRatio = this._knobs.last.ratio;
this._barL = `${(Math.min(firstRatio, lastRatio) * 100)}%`;
this._barR = `${100 - (Math.max(firstRatio, lastRatio) * 100)}%`;
} else {
this._barL = '';
this._barR = `${100 - (firstRatio * 100)}%`;
}
this.updateTicks();
}
/**
* @private
*/
createTicks() {
if (this._snaps) {
this._ticks = [];
for (var value = this._min; value <= this._max; value += this._step) {
var ratio = this.valueToRatio(value);
this._ticks.push({
ratio: ratio,
left: `${ratio * 100}%`,
});
}
this.updateTicks();
} else {
this._ticks = null;
}
}
/**
* @private
*/
updateTicks() {
if (this._snaps) {
let ratio = this.ratio;
if (this._dual) {
let upperRatio = this.ratioUpper;
this._ticks.forEach(t => {
t.active = (t.ratio >= ratio && t.ratio <= upperRatio);
});
} else {
this._ticks.forEach(t => {
t.active = (t.ratio <= ratio);
});
}
}
}
/**
* @private
*/
ratioToValue(ratio: number) {
ratio = Math.round(((this._max - this._min) * ratio) + this._min);
return Math.round(ratio / this._step) * this._step;
}
/**
* @private
*/
valueToRatio(value: number) {
value = Math.round(clamp(this._min, value, this._max) / this._step) * this._step;
return (value - this._min) / (this._max - this._min);
}
/**
* @private
*/
writeValue(val: any) {
if (isPresent(val)) {
let knobs = this._knobs;
this.value = val;
if (this._knobs) {
if (this._dual) {
knobs.first.value = val.lower;
knobs.last.value = val.upper;
knobs.last.position();
} else {
knobs.first.value = val;
}
knobs.first.position();
this.updateBar();
}
}
}
/**
* @private
*/
registerOnChange(fn: Function): void {
this._fn = fn;
this.onChange = (val: any) => {
fn(val);
this.onTouched();
};
}
/**
* @private
*/
registerOnTouched(fn) { this.onTouched = fn; }
/**
* @input {boolean} whether or not the checkbox is disabled or not.
*/
@Input()
get disabled(): boolean {
return this._disabled;
}
set disabled(val: boolean) {
this._disabled = isTrueProperty(val);
this._item && this._item.setCssClass('item-range-disabled', this._disabled);
}
/**
* Returns the ratio of the knob's is current location, which is a number between `0` and `1`.
* If two knobs are used, this property represents the lower value.
*/
get ratio(): number {
if (this._dual) {
return Math.min(this._knobs.first.ratio, this._knobs.last.ratio);
}
return this._knobs.first.ratio;
}
/**
* Returns the ratio of the upper value's is current location, which is a number between `0` and `1`.
* If there is only one knob, then this will return `null`.
*/
get ratioUpper(): number {
if (this._dual) {
return Math.max(this._knobs.first.ratio, this._knobs.last.ratio);
}
return null;
}
/**
* @private
*/
onChange(val: any) {
// used when this input does not have an ngModel or ngControl
this.onTouched();
}
/**
* @private
*/
onTouched() {}
/**
* @private
*/
ngOnDestroy() {
this._form.deregister(this);
this.clearListeners();
}
}
export interface ClientRect {
top?: number;
right?: number;
bottom?: number;
left?: number;
width?: number;
height?: number;
xOffset?: number;
yOffset?: number;
}
export interface Coordinates {
x?: number;
y?: number;
}