feat(range): basic range component

This commit is contained in:
mhartington
2017-08-30 18:35:22 -04:00
parent e0a29db3bb
commit 663deb2694
7 changed files with 835 additions and 1 deletions

View File

@ -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 [
<div class="range-pin" role="presentation">{this.val}</div>,
<div class="range-knob" role="presentation" />
];
}
return <div class="range-knob" role="presentation" />;
}
}
export const KEY_LEFT = 37;
export const KEY_UP = 38;
export const KEY_RIGHT = 39;
export const KEY_DOWN = 40;

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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 [
<slot name="range-start" />,
<ion-gesture
props={{
disableScroll: true,
onPress: this.onPress.bind(this),
onMove: this.onDragMove.bind(this),
onEnd: this.onDragEnd.bind(this),
gestureName: 'range',
gesturePriority: 30,
type: 'pan,press',
direction: 'x',
threshold: 0
}}
>
<div class="range-slider">
<div class="range-bar" role="presentation" />
<div
class="range-bar range-bar-active"
style={{
left: this._barL,
right: this._barR
}}
role="presentation"
/>
<ion-range-knob
class="range-knob-handle"
pressed={this._pressedA}
ratio={this._ratioA}
val={this._valA}
pin={this.pin}
min={this.min}
max={this.max}
/>
</div>
</ion-gesture>,
<slot name="range-end" />
];
}
}

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>Ionic Range</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<script src="/dist/ionic.js"></script>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Range</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item>
<ion-range value="40" id="range">
<ion-icon small name="sunny" slot="range-start"></ion-icon>
<ion-icon name="sunny" slot="range-end"></ion-icon>
</ion-range>
</ion-item>
</ion-list>
<button onClick='elFunction()'> Helllo</button>
</ion-content>
</ion-app>
<script>
function elFunction(){
var range = document.querySelector('ion-range');
console.log(range.ratio())
}
</script>
</body>
</html>

View File

@ -1,5 +1,9 @@
import { StencilElement } from '..'; 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 isDef(v: any): boolean { return v !== undefined && v !== null; }
export function isUndef(v: any): boolean { return v === undefined || v === null; } export function isUndef(v: any): boolean { return v === undefined || v === null; }

View File

@ -28,6 +28,7 @@ exports.config = {
{ components: ['ion-select', 'ion-select-option', 'ion-select-popover'] }, { components: ['ion-select', 'ion-select-option', 'ion-select-popover'] },
{ components: ['ion-slides', 'ion-slide'] }, { components: ['ion-slides', 'ion-slide'] },
{ components: ['ion-spinner'] }, { 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-tabs', 'ion-tab', 'ion-tab-bar', 'ion-tab-button', 'ion-tab-highlight'] },
{ components: ['ion-toggle'] }, { components: ['ion-toggle'] },
{ components: ['ion-nav', 'ion-nav-controller', 'stencil-ion-nav-delegate','page-one', 'page-two', 'page-three'] }, { components: ['ion-nav', 'ion-nav-controller', 'stencil-ion-nav-delegate','page-one', 'page-two', 'page-three'] },