From 2c6e11b10dc53790fd033b1619503adf08a8e87e Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Fri, 27 May 2016 13:20:36 -0500 Subject: [PATCH] feat(range): create ion-range input --- src/components.ios.scss | 1 + src/components.md.scss | 1 + src/components.wp.scss | 1 + src/components/item/item.ts | 2 +- src/components/range/range.ios.scss | 162 ++++++ src/components/range/range.md.scss | 4 + src/components/range/range.ts | 633 +++++++++++++++++++++ src/components/range/range.wp.scss | 4 + src/components/range/test/basic/e2e.ts | 0 src/components/range/test/basic/index.ts | 22 + src/components/range/test/basic/main.html | 133 +++++ src/components/range/test/basic/page1.html | 86 +++ src/config/directives.ts | 2 + 13 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 src/components/range/range.ios.scss create mode 100644 src/components/range/range.md.scss create mode 100644 src/components/range/range.ts create mode 100644 src/components/range/range.wp.scss create mode 100644 src/components/range/test/basic/e2e.ts create mode 100644 src/components/range/test/basic/index.ts create mode 100644 src/components/range/test/basic/main.html create mode 100644 src/components/range/test/basic/page1.html diff --git a/src/components.ios.scss b/src/components.ios.scss index 44fd2e067b2..9e7dac405aa 100644 --- a/src/components.ios.scss +++ b/src/components.ios.scss @@ -25,6 +25,7 @@ "components/picker/picker.ios", "components/popover/popover.ios", "components/radio/radio.ios", + "components/range/range.ios", "components/searchbar/searchbar.ios", "components/segment/segment.ios", "components/select/select.ios", diff --git a/src/components.md.scss b/src/components.md.scss index f5f4a4b93a0..8541e9c6b2f 100644 --- a/src/components.md.scss +++ b/src/components.md.scss @@ -24,6 +24,7 @@ "components/picker/picker.md", "components/popover/popover.md", "components/radio/radio.md", + "components/range/range.md", "components/searchbar/searchbar.md", "components/segment/segment.md", "components/select/select.md", diff --git a/src/components.wp.scss b/src/components.wp.scss index a9ad7f635ef..8bae231b469 100644 --- a/src/components.wp.scss +++ b/src/components.wp.scss @@ -24,6 +24,7 @@ "components/picker/picker.wp", "components/popover/popover.wp", "components/radio/radio.wp", + "components/range/range.wp", "components/searchbar/searchbar.wp", "components/segment/segment.wp", "components/select/select.wp", diff --git a/src/components/item/item.ts b/src/components/item/item.ts index 30cb0edf0b1..28a9682fd7b 100644 --- a/src/components/item/item.ts +++ b/src/components/item/item.ts @@ -48,7 +48,7 @@ import {Label} from '../label/label'; '' + '' + '' + - '' + + '' + '' + '' + '' + diff --git a/src/components/range/range.ios.scss b/src/components/range/range.ios.scss new file mode 100644 index 00000000000..5c729e782a2 --- /dev/null +++ b/src/components/range/range.ios.scss @@ -0,0 +1,162 @@ +@import "../../globals.ios"; + +// iOS Range +// -------------------------------------------------- + +$range-ios-slider-height: 42px !default; + +$range-ios-hit-width: 42px !default; +$range-ios-hit-height: $range-ios-slider-height !default; + +$range-ios-bar-height: 2px !default; +$range-ios-bar-background-color: #bdbdbd !default; +$range-ios-bar-active-background-color: color($colors-ios, primary) !default; + +$range-ios-knob-width: 12px !default; +$range-ios-knob-height: $range-ios-knob-width !default; +$range-ios-knob-background-color: $range-ios-bar-active-background-color !default; + +$range-ios-tick-width: 6px !default; +$range-ios-tick-height: $range-ios-tick-width !default; +$range-ios-tick-background-color: $range-ios-bar-background-color !default; +$range-ios-tick-active-background-color: $range-ios-bar-active-background-color !default; + +$range-ios-pin-background-color: $range-ios-bar-active-background-color !default; +$range-ios-pin-color: color-contrast($colors-ios, $range-ios-pin-background-color) !default; +$range-ios-pin-font-size: 12px !default; + + +.item-range .item-inner { + overflow: visible; +} + +.item-range .input-wrapper { + overflow: visible; + + flex-direction: column; +} + +.item-range ion-range { + width: 100%; +} + +ion-range { + position: relative; + display: block; + + margin-top: -16px; + padding: 8px; +} + +.range-slider { + position: relative; + + height: $range-ios-slider-height; + + cursor: pointer; +} + +.range-bar { + position: absolute; + top: ($range-ios-slider-height / 2); + left: 0; + + width: 100%; + height: $range-ios-bar-height; + + background: $range-ios-bar-background-color; + + pointer-events: none; +} + +.range-pressed .range-bar-active { + will-change: left, right; +} + +.range-pressed .range-knob-handle { + will-change: left; +} + +.range-bar-active { + bottom: 0; + + width: auto; + + background: $range-ios-bar-active-background-color; +} + +.range-knob-handle { + position: absolute; + top: ($range-ios-slider-height / 2); + left: 0%; + + margin-top: -($range-ios-hit-height / 2); + margin-left: -($range-ios-hit-width / 2); + + width: $range-ios-hit-width; + height: $range-ios-hit-height; + + text-align: center; +} + +.range-knob { + position: absolute; + top: ($range-ios-hit-height / 2) - ($range-ios-knob-height / 2) + ($range-ios-bar-height / 2); + left: ($range-ios-hit-width / 2) - ($range-ios-knob-width / 2); + + width: $range-ios-knob-width; + height: $range-ios-knob-height; + + border-radius: 50%; + + background: $range-ios-knob-background-color; + + pointer-events: none; +} + +.range-tick { + position: absolute; + top: ($range-ios-hit-height / 2) - ($range-ios-tick-height / 2) + ($range-ios-bar-height / 2); + + margin-left: ($range-ios-tick-width / 2) * -1; + + width: $range-ios-tick-width; + height: $range-ios-tick-height; + + border-radius: 50%; + + background: $range-ios-tick-background-color; + + pointer-events: none; +} + +.range-tick-active { + background: $range-ios-tick-active-background-color; +} + +.range-pin { + position: relative; + top: -20px; + display: inline-block; + + padding: 8px; + + min-width: 28px; + + border-radius: 50px; + + font-size: $range-ios-pin-font-size; + + text-align: center; + + color: $range-ios-pin-color; + + background: $range-ios-pin-background-color; + + transform: translate3d(0, 28px, 0) scale(.01); + transition: transform 120ms ease; +} + +.range-knob-pressed .range-pin { + transform: translate3d(0, 0, 0) scale(1); +} diff --git a/src/components/range/range.md.scss b/src/components/range/range.md.scss new file mode 100644 index 00000000000..7fb37a2c58d --- /dev/null +++ b/src/components/range/range.md.scss @@ -0,0 +1,4 @@ +@import "../../globals.md"; + +// Material Design Range +// -------------------------------------------------- diff --git a/src/components/range/range.ts b/src/components/range/range.ts new file mode 100644 index 00000000000..3249b027775 --- /dev/null +++ b/src/components/range/range.ts @@ -0,0 +1,633 @@ +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; +} diff --git a/src/components/range/range.wp.scss b/src/components/range/range.wp.scss new file mode 100644 index 00000000000..edf79664cbb --- /dev/null +++ b/src/components/range/range.wp.scss @@ -0,0 +1,4 @@ +@import "../../globals.wp"; + +// Windows Range +// -------------------------------------------------- diff --git a/src/components/range/test/basic/e2e.ts b/src/components/range/test/basic/e2e.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/range/test/basic/index.ts b/src/components/range/test/basic/index.ts new file mode 100644 index 00000000000..7cdfc8d9d66 --- /dev/null +++ b/src/components/range/test/basic/index.ts @@ -0,0 +1,22 @@ +import {App, Page} from '../../../../../src'; + + +@Page({ + templateUrl: 'page1.html' +}) +class Page1 { + singleValue: number; + singleValue2: number = 150; + singleValue3: number = 64; + singleValue4: number = 1300; + dualValue: any; + dualValue2 = {lower: 33, upper: 60}; +} + + +@App({ + templateUrl: 'main.html' +}) +class E2EApp { + rootPage = Page1; +} diff --git a/src/components/range/test/basic/main.html b/src/components/range/test/basic/main.html new file mode 100644 index 00000000000..4f1fff2076b --- /dev/null +++ b/src/components/range/test/basic/main.html @@ -0,0 +1,133 @@ + + + + Left Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Right Menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/range/test/basic/page1.html b/src/components/range/test/basic/page1.html new file mode 100644 index 00000000000..bf0e15ebfb7 --- /dev/null +++ b/src/components/range/test/basic/page1.html @@ -0,0 +1,86 @@ + + + + + + Range + + + + + + + + + + + + + no init value, default min/max, {{singleValue}} + + + + +
{{singleValue2}}
+ init=150, min=-200, max=200 + +
{{singleValue2}}
+
+ + + step=2, {{singleValue3}} + + + + + step=100, snaps, {{singleValue4}} + + + + + dual, {{dualValue | json}} + + + + + dual, step=3, snaps, {{dualValue2 | json}} + + + + + pin, {{singleValue}} + + + + + init=150, min=-200, max=200, {{singleValue2}} + + + + + step=2, {{singleValue3}} + + + + + step=100, snaps, pin, {{singleValue4}} + + + + + dual, pin, {{dualValue | json}} + + + + + dual, step=3, snaps, {{dualValue2 | json}} + + + +
+ +
diff --git a/src/config/directives.ts b/src/config/directives.ts index b499145ff38..a50c130b8b0 100644 --- a/src/config/directives.ts +++ b/src/config/directives.ts @@ -34,6 +34,7 @@ import {Label} from '../components/label/label'; import {Segment, SegmentButton} from '../components/segment/segment'; import {RadioButton} from '../components/radio/radio-button'; import {RadioGroup} from '../components/radio/radio-group'; +import {Range} from '../components/range/range'; import {Searchbar, SearchbarInput} from '../components/searchbar/searchbar'; import {Nav} from '../components/nav/nav'; import {NavPush, NavPop} from '../components/nav/nav-push'; @@ -197,6 +198,7 @@ export const IONIC_DIRECTIVES: any[] = [ Checkbox, RadioGroup, RadioButton, + Range, Select, Option, DateTime,