UNPKG

@ionic/core

Version:

Base components for Ionic

767 lines (766 loc) • 21.4 kB
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;