import { Component, Element, Event, EventEmitter, Listen, Method, Prop, State, Watch } from '@stencil/core'; import { GestureDetail, Mode } from '../../index'; import { clamp, debounceEvent, deferEvent } from '../../utils/helpers'; import { BaseInput } from '../../utils/input-interfaces'; export interface Tick { ratio: number | (() => number); left: string; active?: boolean; } @Component({ tag: 'ion-range', styleUrls: { ios: 'range.ios.scss', md: 'range.md.scss' }, host: { theme: 'range' } }) export class Range implements BaseInput { hasFocus = false; @Element() el!: HTMLElement; @State() barL!: string; @State() barR!: string; @State() valA = 0; @State() valB = 0; @State() ratioA = 0; @State() ratioB = 0; @State() ticks: Tick[] = []; @State() activeB = false; @State() rect!: ClientRect; @State() pressed = false; @State() pressedA = false; @State() pressedB = false; /** * The color to use from your Sass `$colors` map. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. * For more information, see [Theming your App](/docs/theming/theming-your-app). */ @Prop() color!: string; /** * The mode determines which platform styles to use. * Possible values are: `"ios"` or `"md"`. * For more information, see [Platform Styles](/docs/theming/platform-specific-styles). */ @Prop() mode!: Mode; /** * How long, in milliseconds, to wait to trigger the * `ionChange` event after each change in the range value. Default `0`. */ @Prop() debounce = 0; @Watch('debounce') protected debounceChanged() { this.ionChange = debounceEvent(this.ionChange, this.debounce); } /** * The name of the control, which is submitted with the form data. */ @Prop() name = ''; /** * Show two knobs. Defaults to `false`. */ @Prop() dualKnobs = false; /** * Maximum integer value of the range. Defaults to `100`. */ @Prop() max = 100; /** * Minimum integer value of the range. Defaults to `0`. */ @Prop() min = 0; /** * If true, a pin with integer value is shown when the knob * is pressed. Defaults to `false`. */ @Prop() pin = false; /** * If true, the knob snaps to tick marks evenly spaced based * on the step property value. Defaults to `false`. */ @Prop() snaps = false; /** * Specifies the value granularity. Defaults to `1`. */ @Prop() step = 1; /* * If true, the user cannot interact with the range. Defaults to `false`. */ @Prop() disabled = false; @Watch('disabled') protected disabledChanged() { this.emitStyle(); } /** * the value of the range. */ @Prop({ mutable: true }) value: any; @Watch('value') protected valueChanged(val: boolean) { this.ionChange.emit({value: val}); this.inputUpdated(); this.emitStyle(); } /** * Emitted when the value property has changed. */ @Event() ionChange!: EventEmitter; /** * Emitted when the styles change. */ @Event() ionStyle!: EventEmitter; /** * Emitted when the range has focus. */ @Event() ionFocus!: EventEmitter; /** * Emitted when the range loses focus. */ @Event() ionBlur!: EventEmitter; componentWillLoad() { this.ionStyle = deferEvent(this.ionStyle); this.inputUpdated(); this.createTicks(); this.debounceChanged(); this.emitStyle(); } private emitStyle() { this.ionStyle.emit({ 'range-disabled': this.disabled }); } private fireBlur() { if (this.hasFocus) { this.hasFocus = false; this.ionBlur.emit(); this.emitStyle(); } } private fireFocus() { if (!this.hasFocus) { this.hasFocus = true; this.ionFocus.emit(); this.emitStyle(); } } private 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(); } private 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(); } private createTicks() { if (this.snaps) { for (let value = this.min; value <= this.max; value += this.step) { const ratio = this.valueToRatio(value); this.ticks.push({ ratio, left: `${ratio * 100}%` }); } this.updateTicks(); } } private updateTicks() { const ticks = this.ticks; const ratio = this.ratio; if (this.snaps && ticks) { if (this.dualKnobs) { const upperRatio = this.ratioUpper()!; ticks.forEach(t => { t.active = t.ratio >= ratio && t.ratio <= upperRatio; }); } else { ticks.forEach(t => { t.active = t.ratio <= ratio; }); } } } private valueToRatio(value: number) { value = Math.round((value - this.min) / this.step) * this.step; value = value / (this.max - this.min); return clamp(0, value, 1); } private ratioToValue(ratio: number) { ratio = Math.round((this.max - this.min) * ratio); ratio = Math.round(ratio / this.step) * this.step + this.min; return clamp(this.min, ratio, this.max); } // private inputNormalize(val: any): any { // if (this.dualKnobs) { // return val; // } else { // val = parseFloat(val); // return isNaN(val) ? undefined : val; // } // } private 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); const 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) }; } else { // single knob only has one value value = this.valA; } // Update input value this.value = value; return true; } /** * 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. */ @Method() ratio(): number { if (this.dualKnobs) { return Math.min(this.ratioA, this.ratioB); } return this.ratioA; } /** * 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`. */ @Method() ratioUpper() { if (this.dualKnobs) { return Math.max(this.ratioA, this.ratioB); } return null; } @Listen('ionIncrease, ionDecrease') keyChng(ev: RangeEvent) { const step = this.step; if (ev.detail.knob === 'knobB') { if (ev.detail.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(); } private onDragStart(detail: GestureDetail) { if (this.disabled) return false; this.fireFocus(); const current = { x: detail.currentX, y: detail.currentY }; const el = this.el.querySelector('.range-slider')!; const rect = el.getBoundingClientRect(); this.rect = rect; // 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; } private onDragEnd(detail: GestureDetail) { 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(); } private onDragMove(detail: GestureDetail) { if (this.disabled) { return; } const current = { x: detail.currentX, y: detail.currentY }; // update the active knob's position this.update(current, this.rect, true); } hostData() { return { class: { 'range-disabled': this.disabled, 'range-pressed': this.pressed, 'range-has-pin': this.pin } }; } render() { return [ ,
{this.ticks.map(t =>