mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
484 lines
12 KiB
TypeScript
484 lines
12 KiB
TypeScript
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 [
|
|
<slot name='start'></slot>,
|
|
<ion-gesture
|
|
disableScroll={true}
|
|
onStart={this.onDragStart.bind(this)}
|
|
onMove={this.onDragMove.bind(this)}
|
|
onEnd={this.onDragEnd.bind(this)}
|
|
disabled={this.disabled}
|
|
gestureName='range'
|
|
gesturePriority={30}
|
|
type='pan'
|
|
direction='x'
|
|
threshold={0}>
|
|
|
|
<div class='range-slider'>
|
|
{this.ticks.map(t =>
|
|
<div
|
|
style={{ left: t.left! }}
|
|
role='presentation'
|
|
class={{ 'range-tick': true, 'range-tick-active': !!t.active }}
|
|
/>
|
|
)}
|
|
|
|
<div class='range-bar' role='presentation' />
|
|
<div
|
|
class='range-bar range-bar-active'
|
|
role='presentation'
|
|
style={{
|
|
left: this.barL,
|
|
right: this.barR
|
|
}}
|
|
/>
|
|
<ion-range-knob
|
|
class='range-knob-handle'
|
|
knob='knobA'
|
|
pressed={this.pressedA}
|
|
ratio={this.ratioA}
|
|
val={this.valA}
|
|
pin={this.pin}
|
|
min={this.min}
|
|
max={this.max}
|
|
/>
|
|
|
|
{this.dualKnobs
|
|
? <ion-range-knob
|
|
class='range-knob-handle'
|
|
knob='knobB'
|
|
pressed={this.pressedB}
|
|
ratio={this.ratioB}
|
|
val={this.valB}
|
|
pin={this.pin}
|
|
min={this.min}
|
|
max={this.max}
|
|
/>
|
|
: null}
|
|
</div>
|
|
</ion-gesture>,
|
|
<slot name='end'></slot>
|
|
];
|
|
}
|
|
}
|
|
|
|
export interface RangeEvent extends Event {
|
|
detail: {
|
|
isIncrease: boolean,
|
|
knob: string
|
|
};
|
|
}
|