import { Component, ComponentInterface, Element, Event, EventEmitter, Listen, Prop, QueueApi, State, Watch } from '@stencil/core'; import { Color, Gesture, GestureDetail, KnobName, Mode, RangeChangeEventDetail, RangeValue, StyleEventDetail } from '../../interface'; import { clamp, debounceEvent } from '../../utils/helpers'; import { createColorClasses, hostContext } from '../../utils/theme'; /** * @slot start - Content is placed to the left of the range slider in LTR, and to the right in RTL. * @slot end - Content is placed to the right of the range slider in LTR, and to the left in RTL. */ @Component({ tag: 'ion-range', styleUrls: { ios: 'range.ios.scss', md: 'range.md.scss' }, shadow: true }) export class Range implements ComponentInterface { private noUpdate = false; private rect!: ClientRect; private hasFocus = false; private rangeSlider?: HTMLElement; private gesture?: Gesture; @Element() el!: HTMLStencilElement; @Prop({ context: 'queue' }) queue!: QueueApi; @Prop({ context: 'document' }) doc!: Document; @State() private ratioA = 0; @State() private ratioB = 0; @State() private pressedKnob: KnobName; /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. * For more information on colors, see [theming](/docs/theming/basics). */ @Prop() color?: Color; /** * The mode determines which platform styles to use. */ @Prop() mode!: Mode; /** * How long, in milliseconds, to wait to trigger the * `ionChange` event after each change in the range value. */ @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. */ @Prop() dualKnobs = false; /** * Minimum integer value of the range. */ @Prop() min = 0; @Watch('min') protected minChanged() { if (!this.noUpdate) { this.updateRatio(); } } /** * Maximum integer value of the range. */ @Prop() max = 100; @Watch('max') protected maxChanged() { if (!this.noUpdate) { this.updateRatio(); } } /** * If `true`, a pin with integer value is shown when the knob * is pressed. */ @Prop() pin = false; /** * If `true`, the knob snaps to tick marks evenly spaced based * on the step property value. */ @Prop() snaps = false; /** * Specifies the value granularity. */ @Prop() step = 1; /** * If `true`, the user cannot interact with the range. */ @Prop() disabled = false; @Watch('disabled') protected disabledChanged() { if (this.gesture) { this.gesture.setDisabled(this.disabled); } this.emitStyle(); } /** * the value of the range. */ @Prop({ mutable: true }) value: RangeValue = 0; @Watch('value') protected valueChanged(value: RangeValue) { if (!this.noUpdate) { this.updateRatio(); } this.ionChange.emit({ value }); } /** * Emitted when the value property has changed. */ @Event() ionChange!: EventEmitter; /** * Emitted when the styles change. * @internal */ @Event() ionStyle!: EventEmitter; /** * Emitted when the range has focus. */ @Event() ionFocus!: EventEmitter; /** * Emitted when the range loses focus. */ @Event() ionBlur!: EventEmitter; @Listen('focusout') onBlur() { if (this.hasFocus) { this.hasFocus = false; this.ionBlur.emit(); this.emitStyle(); } } @Listen('focusin') onFocus() { if (!this.hasFocus) { this.hasFocus = true; this.ionFocus.emit(); this.emitStyle(); } } componentWillLoad() { this.updateRatio(); this.debounceChanged(); this.emitStyle(); } async componentDidLoad() { this.gesture = (await import('../../utils/gesture')).createGesture({ el: this.rangeSlider!, queue: this.queue, gestureName: 'range', gesturePriority: 100, threshold: 0, onStart: ev => this.onStart(ev), onMove: ev => this.onMove(ev), onEnd: ev => this.onEnd(ev), }); this.gesture.setDisabled(this.disabled); } componentDidUnload() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; } } private handleKeyboard = (knob: KnobName, isIncrease: boolean) => { let step = this.step; step = step > 0 ? step : 1; step = step / (this.max - this.min); if (!isIncrease) { step *= -1; } if (knob === 'A') { this.ratioA = clamp(0, this.ratioA + step, 1); } else { this.ratioB = clamp(0, this.ratioB + step, 1); } this.updateValue(); } private getValue(): RangeValue { const value = this.value || 0; if (this.dualKnobs) { if (typeof value === 'object') { return value; } return { lower: 0, upper: value }; } else { if (typeof value === 'object') { return value.upper; } return value; } } private emitStyle() { this.ionStyle.emit({ 'interactive': true, 'interactive-disabled': this.disabled }); } private onStart(detail: GestureDetail) { const rect = this.rect = this.rangeSlider!.getBoundingClientRect() as any; const currentX = detail.currentX; // figure out which knob they started closer to let ratio = clamp(0, (currentX - rect.left) / rect.width, 1); if (this.doc.dir === 'rtl') { ratio = 1 - ratio; } this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B'; this.setFocus(this.pressedKnob); // update the active knob's position this.update(currentX); } private onMove(detail: GestureDetail) { this.update(detail.currentX); } private onEnd(detail: GestureDetail) { this.update(detail.currentX); this.pressedKnob = undefined; } private update(currentX: number) { // figure out where the pointer is currently at // update the knob being interacted with const rect = this.rect; let ratio = clamp(0, (currentX - rect.left) / rect.width, 1); if (this.doc.dir === 'rtl') { ratio = 1 - ratio; } if (this.snaps) { // snaps the ratio to the current value ratio = valueToRatio( ratioToValue(ratio, this.min, this.max, this.step), this.min, this.max ); } // update which knob is pressed if (this.pressedKnob === 'A') { this.ratioA = ratio; } else { this.ratioB = ratio; } // Update input value this.updateValue(); } private get valA() { return ratioToValue(this.ratioA, this.min, this.max, this.step); } private get valB() { return ratioToValue(this.ratioB, this.min, this.max, this.step); } private get ratioLower() { if (this.dualKnobs) { return Math.min(this.ratioA, this.ratioB); } return 0; } private get ratioUpper() { if (this.dualKnobs) { return Math.max(this.ratioA, this.ratioB); } return this.ratioA; } private updateRatio() { const value = this.getValue() as any; const { min, max } = this; if (this.dualKnobs) { this.ratioA = valueToRatio(value.lower, min, max); this.ratioB = valueToRatio(value.upper, min, max); } else { this.ratioA = valueToRatio(value, min, max); } } private updateValue() { this.noUpdate = true; const { valA, valB } = this; this.value = !this.dualKnobs ? valA : { lower: Math.min(valA, valB), upper: Math.max(valA, valB) }; this.noUpdate = false; } private setFocus(knob: KnobName) { if (this.el.shadowRoot) { const knobEl = this.el.shadowRoot.querySelector(knob === 'A' ? '.range-knob-a' : '.range-knob-b') as HTMLElement | undefined; if (knobEl) { knobEl.focus(); } } } hostData() { return { class: { ...createColorClasses(this.color), 'in-item': hostContext('ion-item', this.el), 'range-disabled': this.disabled, 'range-pressed': this.pressedKnob !== undefined, 'range-has-pin': this.pin } }; } render() { const { min, max, step, ratioLower, ratioUpper } = this; const barStart = `${ratioLower * 100}%`; const barEnd = `${100 - ratioUpper * 100}%`; const doc = this.doc; const isRTL = doc.dir === 'rtl'; const start = isRTL ? 'right' : 'left'; const end = isRTL ? 'left' : 'right'; const ticks = []; if (this.snaps) { for (let value = min; value <= max; value += step) { const ratio = valueToRatio(value, min, max); const tick: any = { ratio, active: ratio >= ratioLower && ratio <= ratioUpper, }; tick[start] = `${ratio * 100}%`; ticks.push(tick); } } const tickStyle = (tick: any) => { const style: any = {}; style[start] = tick[start]; return style; }; const barStyle = () => { const style: any = {}; style[start] = barStart; style[end] = barEnd; return style; }; return [ ,
this.rangeSlider = el}> {ticks.map(tick => (