diff --git a/packages/core/src/components/range/range-knob.tsx b/packages/core/src/components/range/range-knob.tsx
new file mode 100644
index 0000000000..f374804add
--- /dev/null
+++ b/packages/core/src/components/range/range-knob.tsx
@@ -0,0 +1,73 @@
+import { Component, Event, EventEmitter, Listen, Prop } from '@stencil/core';
+
+@Component({
+ tag: `ion-range-knob`
+})
+export class RangeKnob {
+ @Prop() pressed: boolean;
+ @Prop() pin: boolean;
+ @Prop() min: number;
+ @Prop() max: number;
+ @Prop() val: number;
+ @Prop() disabled: boolean;
+ @Prop() labelId: string;
+ @Prop() ratio: number;
+
+ @Event() ionIncrease: EventEmitter;
+ @Event() ionDecrease: EventEmitter;
+
+ @Listen('keydown')
+ handleKeyBoard(ev: KeyboardEvent) {
+ const keyCode = ev.keyCode;
+ if (keyCode === KEY_LEFT || keyCode === KEY_DOWN) {
+ this.ionDecrease.emit({isIncrease: false});
+ ev.preventDefault();
+ ev.stopPropagation();
+ } else if (keyCode === KEY_RIGHT || keyCode === KEY_UP) {
+ this.ionIncrease.emit({isIncrease: true});
+ ev.preventDefault();
+ ev.stopPropagation();
+ }
+ }
+
+ leftPos(val: number) {
+ return `${val * 100}%`;
+ }
+
+ hostData() {
+ return {
+ class: {
+ 'range-knob-pressed': this.pressed,
+ 'range-knob-min': this.val === this.min || this.val === undefined,
+ 'range-knob-max': this.val === this.max
+ },
+ style: {
+ left: this.leftPos(this.ratio)
+ },
+ attrs: {
+ role: 'slider',
+ tabindex: this.disabled ? -1 : 0,
+ 'aria-valuemin': `${this.min}`,
+ 'aria-valuemax': `${this.max}`,
+ 'aria-disabled': `${this.disabled}`,
+ 'aria-labelledby': `${this.labelId}`,
+ 'aria-valuenow': `${this.val}`
+ }
+ };
+ }
+
+ render() {
+ if (this.pin) {
+ return [
+
{this.val}
,
+
+ ];
+ }
+ return ;
+ }
+}
+
+export const KEY_LEFT = 37;
+export const KEY_UP = 38;
+export const KEY_RIGHT = 39;
+export const KEY_DOWN = 40;
diff --git a/packages/core/src/components/range/range.md.scss b/packages/core/src/components/range/range.md.scss
new file mode 100644
index 0000000000..87c4f9d81a
--- /dev/null
+++ b/packages/core/src/components/range/range.md.scss
@@ -0,0 +1,279 @@
+@import "../../themes/ionic.globals.md";
+@import "./range";
+// Material Design Range
+// --------------------------------------------------
+
+/// @prop - Padding top/bottom of the range
+$range-md-padding-vertical: 8px !default;
+
+/// @prop - Padding start/end of the range
+$range-md-padding-horizontal: 8px !default;
+
+/// @prop - Height of the range slider
+$range-md-slider-height: 42px !default;
+
+/// @prop - Width of the area that will select the range knob
+$range-md-hit-width: 42px !default;
+
+/// @prop - Height of the area that will select the range knob
+$range-md-hit-height: $range-md-slider-height !default;
+
+/// @prop - Height of the range bar
+$range-md-bar-height: 2px !default;
+
+/// @prop - Background of the range bar
+$range-md-bar-background-color: #bdbdbd !default;
+
+/// @prop - Background of the active range bar
+$range-md-bar-active-background-color: color($colors-md, primary) !default;
+
+/// @prop - Width of the range knob
+$range-md-knob-width: 18px !default;
+
+/// @prop - Height of the range knob
+$range-md-knob-height: $range-md-knob-width !default;
+
+/// @prop - Background of the range knob
+$range-md-knob-background-color: $range-md-bar-active-background-color !default;
+
+/// @prop - Background of the range knob when the value is the minimum
+$range-md-knob-min-background-color: $background-md-color !default;
+
+/// @prop - Border of the range knob when the value is the minimum
+$range-md-knob-min-border: 2px solid $range-md-bar-background-color !default;
+
+/// @prop - Width of the range tick
+$range-md-tick-width: 2px !default;
+
+/// @prop - Height of the range tick
+$range-md-tick-height: $range-md-tick-width !default;
+
+/// @prop - Border radius of the range tick
+$range-md-tick-border-radius: 50% !default;
+
+/// @prop - Background of the range tick
+$range-md-tick-background-color: #000 !default;
+
+/// @prop - Background of the active range tick
+$range-md-tick-active-background-color: $range-md-tick-background-color !default;
+
+/// @prop - Background of the range pin
+$range-md-pin-background-color: $range-md-bar-active-background-color !default;
+
+/// @prop - Color of the range pin
+$range-md-pin-color: color-contrast($colors-md, $range-md-bar-active-background-color) !default;
+
+/// @prop - Font size of the range pin
+$range-md-pin-font-size: 12px !default;
+
+/// @prop - Padding top/bottom of the range pin
+$range-md-pin-padding-vertical: 8px !default;
+
+/// @prop - Padding start/end of the range pin
+$range-md-pin-padding-horizontal: 0 !default;
+
+/// @prop - Background of the range pin when the value is the minimum
+$range-md-pin-min-background-color: $range-md-bar-background-color !default;
+
+
+.range-md {
+ @include padding($range-md-padding-vertical, $range-md-padding-horizontal);
+}
+
+.range-md [slot="range-start"] {
+ @include margin(0, 12px, 0, 0);
+}
+
+.range-md [slot="range-end"] {
+ @include margin(0, 0, 0, 12px);
+}
+
+.range-md.range-has-pin {
+ @include padding($range-md-padding-vertical + $range-md-pin-font-size + $range-md-pin-padding-vertical, null, null, null);
+}
+
+.range-md .range-slider {
+ height: $range-md-slider-height;
+}
+
+.range-md .range-bar {
+ @include position(($range-md-slider-height / 2), null, null, 0);
+
+ position: absolute;
+
+ width: 100%;
+ height: $range-md-bar-height;
+
+ background: $range-md-bar-background-color;
+
+ pointer-events: none;
+}
+
+.range-md.range-pressed .range-bar-active {
+ will-change: left, right;
+}
+
+.range-md.range-pressed .range-knob-handle {
+ will-change: left;
+}
+
+.range-md .range-bar-active {
+ bottom: 0;
+
+ width: auto;
+
+ background: $range-md-bar-active-background-color;
+}
+
+.range-md .range-knob-handle {
+ @include position(($range-md-slider-height / 2), null, null, 0);
+ @include margin(-($range-md-hit-height / 2), null, null, -($range-md-hit-width / 2));
+ @include text-align(center);
+
+ position: absolute;
+
+ width: $range-md-hit-width;
+ height: $range-md-hit-height;
+}
+
+.range-md .range-knob {
+ @include position(($range-md-hit-height / 2) - ($range-md-knob-height / 2) + ($range-md-bar-height / 2),
+ null, null, ($range-md-hit-width / 2) - ($range-md-knob-width / 2));
+ @include border-radius(50%);
+
+ position: absolute;
+
+ z-index: 2;
+
+ width: $range-md-knob-width;
+ height: $range-md-knob-height;
+
+ background: $range-md-knob-background-color;
+
+ transform: scale(.67);
+ transition-duration: 120ms;
+ transition-property: transform, background-color, border;
+ transition-timing-function: ease;
+
+ pointer-events: none;
+}
+
+.range-md .range-tick {
+ @include margin-horizontal(-($range-md-tick-width / 2), null);
+ @include border-radius($range-md-tick-border-radius);
+
+ position: absolute;
+
+ top: ($range-md-hit-height / 2) - ($range-md-tick-height / 2) + ($range-md-bar-height / 2);
+
+ z-index: 1;
+
+ width: $range-md-tick-width;
+ height: $range-md-tick-height;
+
+ background: $range-md-tick-background-color;
+
+ pointer-events: none;
+}
+
+.range-md .range-tick-active {
+ background: $range-md-tick-active-background-color;
+}
+
+.range-md .range-pin {
+ @include padding($range-md-pin-padding-vertical, $range-md-pin-padding-horizontal);
+ @include text-align(center);
+ @include border-radius(50%);
+ @include transform(translate3d(0, 28px, 0), scale(.01));
+
+ position: relative;
+ top: -20px;
+ display: inline-block;
+
+ min-width: 28px;
+ height: 28px;
+
+ font-size: $range-md-pin-font-size;
+
+ color: $range-md-pin-color;
+
+ background: $range-md-pin-background-color;
+
+ transition: transform 120ms ease, background-color 120ms ease;
+
+ &::before {
+ @include position(3px, null, null, 50%);
+ @include border-radius(50%, 50%, 50%, 0);
+ @include margin-horizontal(-13px, null);
+
+ position: absolute;
+
+ z-index: -1;
+
+ width: 26px;
+ height: 26px;
+
+ background: $range-md-pin-background-color;
+
+ content: "";
+ transform: rotate(-45deg);
+ transition: background-color 120ms ease;
+ }
+}
+
+.range-md .range-knob-pressed .range-pin {
+ @include transform(translate3d(0, 0, 0), scale(1));
+}
+
+.range-md:not(.range-has-pin) .range-knob-pressed .range-knob {
+ transform: scale(1);
+}
+
+@mixin md-range-min() {
+ .range-md .range-knob-min.range-knob-min {
+ .range-knob {
+ border: $range-md-knob-min-border;
+ background: $range-md-knob-min-background-color;
+ }
+
+ .range-pin,
+ .range-pin::before {
+ color: color-contrast($colors-md, $range-md-pin-min-background-color);
+ background: $range-md-pin-min-background-color;
+ }
+ }
+}
+
+@include md-range-min();
+
+.range-md.range-disabled {
+ .range-bar-active {
+ background-color: $range-md-bar-background-color;
+ }
+
+ .range-knob {
+ outline: 5px solid #fff;
+ background-color: $range-md-bar-background-color;
+ transform: scale(.55);
+ }
+
+}
+
+
+// Generate Material Design Range Colors
+// --------------------------------------------------
+
+@each $color-name, $color-base, $color-contrast in get-colors($colors-md) {
+
+ .range-md-#{$color-name} {
+ @include md-range-min();
+
+ .range-bar-active,
+ .range-knob,
+ .range-pin,
+ .range-pin::before {
+ background: $color-base;
+ }
+ }
+
+}
diff --git a/packages/core/src/components/range/range.scss b/packages/core/src/components/range/range.scss
new file mode 100644
index 0000000000..cd13f04164
--- /dev/null
+++ b/packages/core/src/components/range/range.scss
@@ -0,0 +1,56 @@
+@import "../../themes/ionic.globals";
+
+// Range
+// --------------------------------------------------
+
+.item .item-inner {
+ overflow: visible;
+
+ width: 100%;
+}
+
+.item .input-wrapper {
+ overflow: visible;
+
+ flex-direction: column;
+
+ width: 100%;
+}
+
+.item ion-range {
+ width: 100%;
+}
+
+.item ion-range ion-label {
+ align-self: center;
+}
+
+ion-range {
+ position: relative;
+ display: flex;
+
+ align-items: center;
+
+
+ ion-label {
+ flex: initial;
+ }
+
+ ion-icon {
+ min-height: 2.4rem;
+
+ font-size: 2.4rem;
+ line-height: 1;
+
+ }
+
+ ion-gesture,
+ .range-slider {
+ position: relative;
+
+ flex: 1;
+
+ cursor: pointer;
+ }
+}
+
diff --git a/packages/core/src/components/range/range.tsx b/packages/core/src/components/range/range.tsx
new file mode 100644
index 0000000000..a54fb2a3b6
--- /dev/null
+++ b/packages/core/src/components/range/range.tsx
@@ -0,0 +1,383 @@
+import {
+ Component,
+ Element,
+ Event,
+ EventEmitter,
+ Listen,
+ Method,
+ Prop,
+ PropDidChange,
+ State
+} from '@stencil/core';
+import { BaseInputComponent, GestureDetail } from '../../index';
+import { clamp } from '../../utils/helpers';
+
+@Component({
+ tag: 'ion-range',
+ styleUrls: {
+ // ios: 'toggle.ios.scss',
+ md: 'range.md.scss'
+ // wp: 'toggle.wp.scss'
+ },
+ host: {
+ theme: 'range'
+ }
+})
+export class Range implements BaseInputComponent {
+ activated: boolean = false;
+ hasFocus: boolean = false;
+ id: string;
+ labelId: string;
+ startX: number;
+ styleTmr: any;
+
+ @Element() rangeEl: HTMLElement;
+
+ @State() _barL: string;
+ @State() _barR: string;
+ @State() _valA: number = 0;
+ @State() _valB: number = 0;
+ @State() _ratioA: number = 0;
+ @State() _ratioB: number = 0;
+ @State() _ticks: any[];
+ @State() _activeB: boolean;
+ @State() _rect: ClientRect;
+
+ @State() _pressed: boolean;
+ @State() _pressedA: boolean;
+ @State() _pressedB: boolean;
+
+ @Event() ionChange: EventEmitter;
+ @Event() ionStyle: EventEmitter;
+ @Event() ionFocus: EventEmitter;
+ @Event() ionBlur: EventEmitter;
+ //
+ // @Prop() color: string;
+ // @Prop() mode: string;
+
+ @Prop({ state: true })
+ value: any;
+ @Prop() disabled: boolean = false;
+ @Prop() min: number = 0;
+ @Prop() max: number = 100;
+ @Prop() steps: number = 1;
+ @Prop() dualKnobs: boolean = false;
+ @Prop() pin: boolean = false;
+ @Prop() snaps: boolean = false;
+ @Prop() debounce: number = 0;
+
+ private canStart() {
+ return !this.disabled;
+ }
+
+ fireBlur() {
+ if (this.hasFocus) {
+ this.hasFocus = false;
+ this.ionBlur.emit();
+ this.emitStyle();
+ }
+ }
+
+ @PropDidChange('disabled')
+ disabledChanged() {
+ this.emitStyle();
+ }
+
+ ionViewWillLoad() {
+ this._inputUpdated();
+ this.emitStyle();
+ }
+
+ private emitStyle() {
+ clearTimeout(this.styleTmr);
+
+ this.styleTmr = setTimeout(() => {
+ this.ionStyle.emit({
+ 'range-disabled': this.disabled
+ });
+ });
+ }
+
+ fireFocus() {
+ if (!this.hasFocus) {
+ this.hasFocus = true;
+ this.ionFocus.emit();
+ this.emitStyle();
+ }
+ }
+
+ _inputUpdated() {
+ const val = this.value;
+ if (this.dualKnobs) {
+ this._valA = val.lower;
+ this._valB = val.upper;
+ this._ratioA = this._valueToRatio(val.lower);
+ this._ratioB = this._valueToRatio(val.upper);
+ } else {
+ this._valA = val;
+ this._ratioA = this._valueToRatio(val);
+ }
+
+ this._updateBar();
+ }
+
+ _updateBar() {
+ const ratioA = this._ratioA;
+ const ratioB = this._ratioB;
+
+ if (this.dualKnobs) {
+ this._barL = `${Math.min(ratioA, ratioB) * 100}%`;
+ this._barR = `${100 - Math.max(ratioA, ratioB) * 100}%`;
+ } else {
+ this._barL = '';
+ this._barR = `${100 - ratioA * 100}%`;
+ }
+
+ this._updateTicks();
+ }
+
+ _creatTicks() {
+ if (this.snaps) {
+ this._ticks = [];
+ for (let value = this.min; value <= this.max; value += this.steps) {
+ let ratio = this._valueToRatio(value);
+ this._ticks.push({
+ ratio,
+ left: `${ratio * 100}%`
+ });
+ }
+ this._updateTicks();
+ }
+ }
+
+ _updateTicks() {
+ const ticks = this._ticks;
+ const ratio = this.ratio;
+
+ if (this.snaps && ticks) {
+ if (this.dualKnobs) {
+ let upperRatio = this.ratioUpper();
+
+ ticks.forEach(t => {
+ t.active = t.ratio >= ratio && t.ratio <= upperRatio;
+ });
+ } else {
+ ticks.forEach(t => {
+ t.active = t.ratio <= ratio;
+ });
+ }
+ }
+ }
+
+ _valueToRatio(value: number) {
+ value = Math.round((value - this.min) / this.steps) * this.steps;
+ value = value / (this.max - this.min);
+ return clamp(0, value, 1);
+ }
+
+ _ratioToValue(ratio: number) {
+ ratio = Math.round((this.max - this.min) * ratio);
+ ratio = Math.round(ratio / this.steps) * this.steps + this.min;
+ return clamp(this.min, ratio, this.max);
+ }
+
+ _inputNormalize(val: any): any {
+ if (this.dualKnobs) {
+ return val;
+ } else {
+ val = parseFloat(val);
+ return isNaN(val) ? undefined : val;
+ }
+ }
+
+ _update(
+ current: { x?: number; y?: number },
+ rect: ClientRect,
+ isPressed: boolean
+ ) {
+ // figure out where the pointer is currently at
+ // update the knob being interacted with
+ let ratio = clamp(0, (current.x - rect.left) / rect.width, 1);
+ let val = this._ratioToValue(ratio);
+
+ if (this.snaps) {
+ // snaps the ratio to the current value
+ ratio = this._valueToRatio(val);
+ }
+
+ // update which knob is pressed
+ this._pressed = isPressed;
+ let valChanged = false;
+ if (this._activeB) {
+ // when the pointer down started it was determined
+ // that knob B was the one they were interacting with
+ this._pressedB = isPressed;
+ this._pressedA = false;
+ this._ratioB = ratio;
+ valChanged = val === this._valB;
+ this._valB = val;
+ } else {
+ // interacting with knob A
+ this._pressedA = isPressed;
+ this._pressedB = false;
+ this._ratioA = ratio;
+ valChanged = val === this._valA;
+ this._valA = val;
+ }
+ this._updateBar();
+ if (valChanged) {
+ return false;
+ }
+
+ // value has been updated
+ let value;
+ if (this.dualKnobs) {
+ // dual knobs have an lower and upper value
+ value = {
+ lower: Math.min(this._valA, this._valB),
+ upper: Math.max(this._valA, this._valB)
+ };
+
+ console.debug(
+ `range, updateKnob: ${ratio}, lower: ${this.value.lower}, upper: ${this
+ .value.upper}`
+ );
+ } else {
+ // single knob only has one value
+ value = this._valA;
+ console.debug(`range, updateKnob: ${ratio}, value: ${this.value}`);
+ }
+
+ // Update input value
+ this.value = value;
+
+ return true;
+ }
+
+ @Method()
+ ratio(): number {
+ if (this.dualKnobs) {
+ return Math.min(this._ratioA, this._ratioB);
+ }
+ return this._ratioA;
+ }
+
+ @Method()
+ ratioUpper() {
+ if (this.dualKnobs) {
+ return Math.max(this._ratioA, this._ratioB);
+ }
+ return null;
+ }
+
+ @Listen('ionIncrease, ionDecrease')
+ _keyChg(ev: any) {
+ const step = this.steps;
+ // if (isKnobB) {
+ // if (isIncrease) {
+ // this._valB += step;
+ // } else {
+ // this._valB -= step;
+ // }
+ // this._valB = clamp(this.min, this._valB, this.max);
+ // this._ratioB = this._valueToRatio(this._valB);
+ // } else {
+ if (!!ev.detail.isIncrease) {
+ this._valA += step;
+ } else {
+ this._valA -= step;
+ }
+ this._valA = clamp(this.min, this._valA, this.max);
+ this._ratioA = this._valueToRatio(this._valA);
+ // }
+ this._updateBar();
+ }
+
+ onPress(detail: GestureDetail) {
+ console.log('on press')
+ if (this.disabled) {
+ return false;
+ }
+ this.fireFocus();
+
+ const current = { x: detail.currentX, y: detail.currentY };
+ const rect = (this._rect = this.rangeEl.getBoundingClientRect());
+ // figure out which knob they started closer to
+ const ratio = clamp(0, (current.x - rect.left) / rect.width, 1);
+
+ this._activeB =
+ this.dualKnobs &&
+ Math.abs(ratio - this._ratioA) > Math.abs(ratio - this._ratioB);
+
+ // update the active knob's position
+ this._update(current, rect, true);
+
+ // return true so the pointer events
+ // know everything's still valid
+ return true;
+ }
+
+ onDragMove(detail: GestureDetail) {
+ console.log('drag start')
+ if (this.disabled) {
+ return;
+ }
+ const current = { x: detail.currentX, y: detail.currentY };
+ // update the active knob's position
+ this._update(current, this._rect, true);
+ }
+
+ onDragEnd(detail: GestureDetail) {
+ console.log('drag end')
+ if (this.disabled) {
+ return;
+ }
+ // update the active knob's position
+ this._update({ x: detail.currentX, y: detail.currentY }, this._rect, false);
+
+ // trigger ionBlur event
+ this.fireBlur();
+ }
+
+ render() {
+ return [
+ ,
+
+
+
+ ,
+
+ ];
+ }
+}
diff --git a/packages/core/src/components/range/test/basic.html b/packages/core/src/components/range/test/basic.html
new file mode 100644
index 0000000000..59c03918e8
--- /dev/null
+++ b/packages/core/src/components/range/test/basic.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Ionic Range
+
+
+
+
+
+
+
+
+ Range
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/utils/helpers.ts b/packages/core/src/utils/helpers.ts
index c8ffc11952..a54085a3ab 100644
--- a/packages/core/src/utils/helpers.ts
+++ b/packages/core/src/utils/helpers.ts
@@ -1,5 +1,9 @@
import { StencilElement } from '..';
+export function clamp(min: number, n: number, max: number) {
+ return Math.max(min, Math.min(n, max));
+}
+
export function isDef(v: any): boolean { return v !== undefined && v !== null; }
export function isUndef(v: any): boolean { return v === undefined || v === null; }
@@ -171,4 +175,4 @@ export function isReady(element: HTMLElement) {
/** @hidden */
export function deepCopy(obj: any) {
return JSON.parse(JSON.stringify(obj));
-}
\ No newline at end of file
+}
diff --git a/packages/core/stencil.config.js b/packages/core/stencil.config.js
index b9c63c9378..641cb61849 100644
--- a/packages/core/stencil.config.js
+++ b/packages/core/stencil.config.js
@@ -28,6 +28,7 @@ exports.config = {
{ components: ['ion-select', 'ion-select-option', 'ion-select-popover'] },
{ components: ['ion-slides', 'ion-slide'] },
{ components: ['ion-spinner'] },
+ { components: ['ion-range', 'ion-range-knob']},
{ components: ['ion-tabs', 'ion-tab', 'ion-tab-bar', 'ion-tab-button', 'ion-tab-highlight'] },
{ components: ['ion-toggle'] },
{ components: ['ion-nav', 'ion-nav-controller', 'stencil-ion-nav-delegate','page-one', 'page-two', 'page-three'] },