@ionic/core
Version:
Base components for Ionic
767 lines (766 loc) • 21.4 kB
JavaScript
import { Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import { clamp, debounceEvent, getAriaLabel, inheritAttributes, renderHiddenInput } from '../../utils/helpers';
import { createColorClasses, hostContext } from '../../utils/theme';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @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.
*
* @part tick - An inactive tick mark.
* @part tick-active - An active tick mark.
* @part pin - The counter that appears above a knob.
* @part knob - The handle that is used to drag the range.
* @part bar - The inactive part of the bar.
* @part bar-active - The active part of the bar.
*/
export class Range {
constructor() {
this.didLoad = false;
this.noUpdate = false;
this.hasFocus = false;
this.inheritedAttributes = {};
this.ratioA = 0;
this.ratioB = 0;
/**
* How long, in milliseconds, to wait to trigger the
* `ionChange` event after each change in the range value.
* This also impacts form bindings such as `ngModel` or `v-model`.
*/
this.debounce = 0;
// TODO: In Ionic Framework v6 this should initialize to this.rangeId like the other form components do.
/**
* The name of the control, which is submitted with the form data.
*/
this.name = '';
/**
* Show two knobs.
*/
this.dualKnobs = false;
/**
* Minimum integer value of the range.
*/
this.min = 0;
/**
* Maximum integer value of the range.
*/
this.max = 100;
/**
* If `true`, a pin with integer value is shown when the knob
* is pressed.
*/
this.pin = false;
/**
* If `true`, the knob snaps to tick marks evenly spaced based
* on the step property value.
*/
this.snaps = false;
/**
* Specifies the value granularity.
*/
this.step = 1;
/**
* If `true`, tick marks are displayed based on the step value.
* Only applies when `snaps` is `true`.
*/
this.ticks = true;
/**
* If `true`, the user cannot interact with the range.
*/
this.disabled = false;
/**
* the value of the range.
*/
this.value = 0;
this.clampBounds = (value) => {
return clamp(this.min, value, this.max);
};
this.ensureValueInBounds = (value) => {
if (this.dualKnobs) {
return {
lower: this.clampBounds(value.lower),
upper: this.clampBounds(value.upper)
};
}
else {
return this.clampBounds(value);
}
};
this.setupGesture = async () => {
const rangeSlider = this.rangeSlider;
if (rangeSlider) {
this.gesture = (await import('../../utils/gesture')).createGesture({
el: rangeSlider,
gestureName: 'range',
gesturePriority: 100,
threshold: 0,
onStart: ev => this.onStart(ev),
onMove: ev => this.onMove(ev),
onEnd: ev => this.onEnd(ev),
});
this.gesture.enable(!this.disabled);
}
};
this.handleKeyboard = (knob, isIncrease) => {
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();
};
this.onBlur = () => {
if (this.hasFocus) {
this.hasFocus = false;
this.ionBlur.emit();
this.emitStyle();
}
};
this.onFocus = () => {
if (!this.hasFocus) {
this.hasFocus = true;
this.ionFocus.emit();
this.emitStyle();
}
};
}
debounceChanged() {
this.ionChange = debounceEvent(this.ionChange, this.debounce);
}
minChanged() {
if (!this.noUpdate) {
this.updateRatio();
}
}
maxChanged() {
if (!this.noUpdate) {
this.updateRatio();
}
}
disabledChanged() {
if (this.gesture) {
this.gesture.enable(!this.disabled);
}
this.emitStyle();
}
valueChanged(value) {
if (!this.noUpdate) {
this.updateRatio();
}
value = this.ensureValueInBounds(value);
this.ionChange.emit({ value });
}
componentWillLoad() {
/**
* If user has custom ID set then we should
* not assign the default incrementing ID.
*/
this.rangeId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id') : `ion-r-${rangeIds++}`;
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
}
componentDidLoad() {
this.setupGesture();
this.didLoad = true;
}
connectedCallback() {
this.updateRatio();
this.debounceChanged();
this.disabledChanged();
/**
* If we have not yet rendered
* ion-range, then rangeSlider is not defined.
* But if we are moving ion-range via appendChild,
* then rangeSlider will be defined.
*/
if (this.didLoad) {
this.setupGesture();
}
}
disconnectedCallback() {
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
}
getValue() {
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;
}
}
emitStyle() {
this.ionStyle.emit({
'interactive': true,
'interactive-disabled': this.disabled
});
}
onStart(detail) {
const rect = this.rect = this.rangeSlider.getBoundingClientRect();
const currentX = detail.currentX;
// figure out which knob they started closer to
let ratio = clamp(0, (currentX - rect.left) / rect.width, 1);
if (document.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);
}
onMove(detail) {
this.update(detail.currentX);
}
onEnd(detail) {
this.update(detail.currentX);
this.pressedKnob = undefined;
}
update(currentX) {
// 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 (document.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();
}
get valA() {
return ratioToValue(this.ratioA, this.min, this.max, this.step);
}
get valB() {
return ratioToValue(this.ratioB, this.min, this.max, this.step);
}
get ratioLower() {
if (this.dualKnobs) {
return Math.min(this.ratioA, this.ratioB);
}
return 0;
}
get ratioUpper() {
if (this.dualKnobs) {
return Math.max(this.ratioA, this.ratioB);
}
return this.ratioA;
}
updateRatio() {
const value = this.getValue();
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);
}
}
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;
}
setFocus(knob) {
if (this.el.shadowRoot) {
const knobEl = this.el.shadowRoot.querySelector(knob === 'A' ? '.range-knob-a' : '.range-knob-b');
if (knobEl) {
knobEl.focus();
}
}
}
render() {
const { min, max, step, el, handleKeyboard, pressedKnob, disabled, pin, ratioLower, ratioUpper, inheritedAttributes, rangeId } = this;
/**
* Look for external label, ion-label, or aria-labelledby.
* If none, see if user placed an aria-label on the host
* and use that instead.
*/
let { labelText } = getAriaLabel(el, rangeId);
if (labelText === undefined || labelText === null) {
labelText = inheritedAttributes['aria-label'];
}
const mode = getIonMode(this);
const barStart = `${ratioLower * 100}%`;
const barEnd = `${100 - ratioUpper * 100}%`;
const doc = document;
const isRTL = doc.dir === 'rtl';
const start = isRTL ? 'right' : 'left';
const end = isRTL ? 'left' : 'right';
const tickStyle = (tick) => {
return {
[start]: tick[start]
};
};
const barStyle = {
[start]: barStart,
[end]: barEnd
};
const ticks = [];
if (this.snaps && this.ticks) {
for (let value = min; value <= max; value += step) {
const ratio = valueToRatio(value, min, max);
const tick = {
ratio,
active: ratio >= ratioLower && ratio <= ratioUpper,
};
tick[start] = `${ratio * 100}%`;
ticks.push(tick);
}
}
renderHiddenInput(true, el, this.name, JSON.stringify(this.getValue()), disabled);
return (h(Host, { onFocusin: this.onFocus, onFocusout: this.onBlur, id: rangeId, class: createColorClasses(this.color, {
[mode]: true,
'in-item': hostContext('ion-item', el),
'range-disabled': disabled,
'range-pressed': pressedKnob !== undefined,
'range-has-pin': pin
}) },
h("slot", { name: "start" }),
h("div", { class: "range-slider", ref: rangeEl => this.rangeSlider = rangeEl },
ticks.map(tick => (h("div", { style: tickStyle(tick), role: "presentation", class: {
'range-tick': true,
'range-tick-active': tick.active
}, part: tick.active ? 'tick-active' : 'tick' }))),
h("div", { class: "range-bar", role: "presentation", part: "bar" }),
h("div", { class: "range-bar range-bar-active", role: "presentation", style: barStyle, part: "bar-active" }),
renderKnob(isRTL, {
knob: 'A',
pressed: pressedKnob === 'A',
value: this.valA,
ratio: this.ratioA,
pin,
disabled,
handleKeyboard,
min,
max,
labelText
}),
this.dualKnobs && renderKnob(isRTL, {
knob: 'B',
pressed: pressedKnob === 'B',
value: this.valB,
ratio: this.ratioB,
pin,
disabled,
handleKeyboard,
min,
max,
labelText
})),
h("slot", { name: "end" })));
}
static get is() { return "ion-range"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() { return {
"ios": ["range.ios.scss"],
"md": ["range.md.scss"]
}; }
static get styleUrls() { return {
"ios": ["range.ios.css"],
"md": ["range.md.css"]
}; }
static get properties() { return {
"color": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Color",
"resolved": "string | undefined",
"references": {
"Color": {
"location": "import",
"path": "../../interface"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "The color to use from your application's color palette.\nDefault options are: `\"primary\"`, `\"secondary\"`, `\"tertiary\"`, `\"success\"`, `\"warning\"`, `\"danger\"`, `\"light\"`, `\"medium\"`, and `\"dark\"`.\nFor more information on colors, see [theming](/docs/theming/basics)."
},
"attribute": "color",
"reflect": true
},
"debounce": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "How long, in milliseconds, to wait to trigger the\n`ionChange` event after each change in the range value.\nThis also impacts form bindings such as `ngModel` or `v-model`."
},
"attribute": "debounce",
"reflect": false,
"defaultValue": "0"
},
"name": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The name of the control, which is submitted with the form data."
},
"attribute": "name",
"reflect": false,
"defaultValue": "''"
},
"dualKnobs": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Show two knobs."
},
"attribute": "dual-knobs",
"reflect": false,
"defaultValue": "false"
},
"min": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Minimum integer value of the range."
},
"attribute": "min",
"reflect": false,
"defaultValue": "0"
},
"max": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Maximum integer value of the range."
},
"attribute": "max",
"reflect": false,
"defaultValue": "100"
},
"pin": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, a pin with integer value is shown when the knob\nis pressed."
},
"attribute": "pin",
"reflect": false,
"defaultValue": "false"
},
"snaps": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, the knob snaps to tick marks evenly spaced based\non the step property value."
},
"attribute": "snaps",
"reflect": false,
"defaultValue": "false"
},
"step": {
"type": "number",
"mutable": false,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the value granularity."
},
"attribute": "step",
"reflect": false,
"defaultValue": "1"
},
"ticks": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, tick marks are displayed based on the step value.\nOnly applies when `snaps` is `true`."
},
"attribute": "ticks",
"reflect": false,
"defaultValue": "true"
},
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "If `true`, the user cannot interact with the range."
},
"attribute": "disabled",
"reflect": false,
"defaultValue": "false"
},
"value": {
"type": "number",
"mutable": true,
"complexType": {
"original": "RangeValue",
"resolved": "number | { lower: number; upper: number; }",
"references": {
"RangeValue": {
"location": "import",
"path": "../../interface"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "the value of the range."
},
"attribute": "value",
"reflect": false,
"defaultValue": "0"
}
}; }
static get states() { return {
"ratioA": {},
"ratioB": {},
"pressedKnob": {}
}; }
static get events() { return [{
"method": "ionChange",
"name": "ionChange",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when the value property has changed."
},
"complexType": {
"original": "RangeChangeEventDetail",
"resolved": "RangeChangeEventDetail",
"references": {
"RangeChangeEventDetail": {
"location": "import",
"path": "../../interface"
}
}
}
}, {
"method": "ionStyle",
"name": "ionStyle",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [{
"text": undefined,
"name": "internal"
}],
"text": "Emitted when the styles change."
},
"complexType": {
"original": "StyleEventDetail",
"resolved": "StyleEventDetail",
"references": {
"StyleEventDetail": {
"location": "import",
"path": "../../interface"
}
}
}
}, {
"method": "ionFocus",
"name": "ionFocus",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when the range has focus."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "ionBlur",
"name": "ionBlur",
"bubbles": true,
"cancelable": true,
"composed": true,
"docs": {
"tags": [],
"text": "Emitted when the range loses focus."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}]; }
static get elementRef() { return "el"; }
static get watchers() { return [{
"propName": "debounce",
"methodName": "debounceChanged"
}, {
"propName": "min",
"methodName": "minChanged"
}, {
"propName": "max",
"methodName": "maxChanged"
}, {
"propName": "disabled",
"methodName": "disabledChanged"
}, {
"propName": "value",
"methodName": "valueChanged"
}]; }
}
const renderKnob = (isRTL, { knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard, labelText }) => {
const start = isRTL ? 'right' : 'left';
const knobStyle = () => {
const style = {};
style[start] = `${ratio * 100}%`;
return style;
};
return (h("div", { onKeyDown: (ev) => {
const key = ev.key;
if (key === 'ArrowLeft' || key === 'ArrowDown') {
handleKeyboard(knob, false);
ev.preventDefault();
ev.stopPropagation();
}
else if (key === 'ArrowRight' || key === 'ArrowUp') {
handleKeyboard(knob, true);
ev.preventDefault();
ev.stopPropagation();
}
}, class: {
'range-knob-handle': true,
'range-knob-a': knob === 'A',
'range-knob-b': knob === 'B',
'range-knob-pressed': pressed,
'range-knob-min': value === min,
'range-knob-max': value === max
}, style: knobStyle(), role: "slider", tabindex: disabled ? -1 : 0, "aria-label": labelText, "aria-valuemin": min, "aria-valuemax": max, "aria-disabled": disabled ? 'true' : null, "aria-valuenow": value },
pin && h("div", { class: "range-pin", role: "presentation", part: "pin" }, Math.round(value)),
h("div", { class: "range-knob", role: "presentation", part: "knob" })));
};
const ratioToValue = (ratio, min, max, step) => {
let value = (max - min) * ratio;
if (step > 0) {
value = Math.round(value / step) * step + min;
}
return clamp(min, value, max);
};
const valueToRatio = (value, min, max) => {
return clamp(0, (value - min) / (max - min), 1);
};
let rangeIds = 0;