/
githubmirror
/
ionic
Обзор
Документация
Войти
/
githubmirror
/
ionic
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
core/src/components/range/range.tsx
1 257 строк
40 KB
Brandy Smith
feat(range): add classes and expose parts to allow individual styling of dual knobs (#30941)
04 мар 2026, 18:36
Не верифицирован
04 мар 2026, 18:36
5bcf921
Код
Авторство
О чём код?
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Prop, State, Watch, h } from '@stencil/core'; import { findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, clamp, debounceEvent, renderHiddenInput, isSafeNumber } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { isRTL } from '@utils/rtl'; import { createColorClasses, hostContext } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; import type { Color, Gesture, GestureDetail } from '../../interface'; import { roundToMaxDecimalPlaces } from '../../utils/floating-point'; import type { KnobName, KnobPosition, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue, PinFormatter, } from './range-interface'; // TODO(FW-2832): types /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @slot label - The label text to associate with the range. Use the "labelPlacement" property to control where the label is placed relative to the range. * @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 label - The label text describing the range. * @part tick - An inactive tick mark. * @part tick-active - An active tick mark. * @part bar - The inactive part of the bar. * @part bar-active - The active part of the bar. * @part knob-handle - The container that wraps the knob and handles drag interactions. * @part knob-handle-a - The container for the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part knob-handle-b - The container for the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part knob-handle-lower - The container for the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross. * @part knob-handle-upper - The container for the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob handle they refer to when the knobs cross. * @part pin - The value indicator displayed above a knob. * @part pin-a - The value indicator above the knob with the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part pin-b - The value indicator above the knob with the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part pin-lower - The value indicator above the knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross. * @part pin-upper - The value indicator above the knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which pin they refer to when the knobs cross. * @part knob - The visual knob element on the range track. * @part knob-a - The visual knob for the static `A` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part knob-b - The visual knob for the static `B` identity when `dualKnobs` is `true`. This identity does not change, even if the knobs cross and swap which one represents the lower or upper value. * @part knob-lower - The visual knob whose current `value` is `lower` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross. * @part knob-upper - The visual knob whose current `value` is `upper` when `dualKnobs` is `true`. The lower and upper parts swap which knob they refer to when the knobs cross. * @part activated - Added to the knob-handle, knob, and pin when the knob is active. Only one set has this part at a time when `dualKnobs` is `true`. * @part focused - Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time when `dualKnobs` is `true`. * @part hover - Added to the knob-handle, knob, and pin when the knob has hover. Only one set has this part at a time when `dualKnobs` is `true`. * @part pressed - Added to the knob-handle, knob, and pin that is currently being pressed to drag. Only one set has this part at a time when `dualKnobs` is `true`. */ @Component({ tag: 'ion-range', styleUrls: { ios: 'range.ios.scss', md: 'range.md.scss', }, shadow: true, }) export class Range implements ComponentInterface { private rangeId = `ion-r-${rangeIds++}`; private didLoad = false; private noUpdate = false; private rect!: ClientRect; private hasFocus = false; private rangeSlider?: HTMLElement; private gesture?: Gesture; private inheritedAttributes: Attributes = {}; private contentEl: HTMLElement | null = null; private initialContentScrollY = true; private originalIonInput?: EventEmitter<RangeChangeEventDetail>; /** * Used to avoid setting the focused state on click or tap. The focused * state is only set when the focus comes from the keyboard (e.g. Tab). * This is set to true on pointer down (mouse/touch). */ private focusFromPointer = false; /** * Observes class changes on the knob handles to keep the activatedKnob * state in sync with the ion-activated class. This is necessary to * determine which knob the user is dragging when using dual knobs and * apply the activated part correctly. */ private activatedObserver?: MutationObserver; @Element() el!: HTMLIonRangeElement; @State() private ratioA = 0; @State() private ratioB = 0; @State() private activatedKnob: KnobName; @State() private focusedKnob: KnobName; @State() private hoveredKnob: KnobName; @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({ reflect: true }) color?: Color; /** * How long, in milliseconds, to wait to trigger the * `ionInput` event after each change in the range value. */ @Prop() debounce?: number; @Watch('debounce') protected debounceChanged() { const { ionInput, debounce, originalIonInput } = this; /** * If debounce is undefined, we have to manually revert the ionInput emitter in case * debounce used to be set to a number. Otherwise, the event would stay debounced. */ this.ionInput = debounce === undefined ? originalIonInput ?? ionInput : debounceEvent(ionInput, debounce); } /** * The name of the control, which is submitted with the form data. */ @Prop() name = this.rangeId; /** * The text to display as the control's label. Use this over the `label` slot if * you only need plain text. The `label` property will take priority over the * `label` slot if both are used. */ @Prop() label?: string; /** * Show two knobs. */ @Prop() dualKnobs = false; /** * Minimum integer value of the range. */ @Prop() min = 0; @Watch('min') protected minChanged(newValue: number) { if (!isSafeNumber(newValue)) { this.min = 0; } if (!this.noUpdate) { this.updateRatio(); } } /** * Maximum integer value of the range. */ @Prop() max = 100; @Watch('max') protected maxChanged(newValue: number) { if (!isSafeNumber(newValue)) { this.max = 100; } if (!this.noUpdate) { this.updateRatio(); } } /** * If `true`, a pin with integer value is shown when the knob * is pressed. */ @Prop() pin = false; /** * A callback used to format the pin text. * By default the pin text is set to `Math.round(value)`. * * See https://ionicframework.com/docs/troubleshooting/runtime#accessing-this * if you need to access `this` from within the callback. */ @Prop() pinFormatter: PinFormatter = (value: number): number => Math.round(value); /** * 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; @Watch('step') protected stepChanged(newValue: number) { if (!isSafeNumber(newValue)) { this.step = 1; } } /** * If `true`, tick marks are displayed based on the step value. * Only applies when `snaps` is `true`. */ @Prop() ticks = true; /** * The start position of the range active bar. This feature is only available with a single knob (dualKnobs="false"). * Valid values are greater than or equal to the min value and less than or equal to the max value. */ @Prop({ mutable: true }) activeBarStart?: number; @Watch('activeBarStart') protected activeBarStartChanged() { const { activeBarStart } = this; if (activeBarStart !== undefined) { if (activeBarStart > this.max) { printIonWarning( `[ion-range] - The value of activeBarStart (${activeBarStart}) is greater than the max (${this.max}). Valid values are greater than or equal to the min value and less than or equal to the max value.`, this.el ); this.activeBarStart = this.max; } else if (activeBarStart < this.min) { printIonWarning( `[ion-range] - The value of activeBarStart (${activeBarStart}) is less than the min (${this.min}). Valid values are greater than or equal to the min value and less than or equal to the max value.`, this.el ); this.activeBarStart = this.min; } } } /** * If `true`, the user cannot interact with the range. */ @Prop() disabled = false; @Watch('disabled') protected disabledChanged() { if (this.gesture) { this.gesture.enable(!this.disabled); } } /** * the value of the range. */ @Prop({ mutable: true }) value: RangeValue = 0; @Watch('value') protected valueChanged(newValue: RangeValue, oldValue: RangeValue) { const valuesChanged = this.compareValues(newValue, oldValue); if (valuesChanged) { this.ionInput.emit({ value: this.value }); } if (!this.noUpdate) { this.updateRatio(); } } /** * Compares two RangeValue inputs to determine if they are different. * * @param newVal - The new value. * @param oldVal - The old value. * @returns `true` if the values are different, `false` otherwise. */ private compareValues = (newVal: RangeValue, oldVal: RangeValue) => { if (typeof newVal === 'object' && typeof oldVal === 'object') { return newVal.lower !== oldVal.lower || newVal.upper !== oldVal.upper; } return newVal !== oldVal; }; private clampBounds = (value: any): number => { return clamp(this.min, value, this.max); }; private ensureValueInBounds = (value: any) => { if (this.dualKnobs) { return { lower: this.clampBounds(value.lower), upper: this.clampBounds(value.upper), }; } else { return this.clampBounds(value); } }; /** * Where to place the label relative to the range. * `"start"`: The label will appear to the left of the range in LTR and to the right in RTL. * `"end"`: The label will appear to the right of the range in LTR and to the left in RTL. * `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). * `"stacked"`: The label will appear above the range regardless of the direction. */ @Prop() labelPlacement: 'start' | 'end' | 'fixed' | 'stacked' = 'start'; /** * The `ionChange` event is fired for `<ion-range>` elements when the user * modifies the element's value: * - When the user releases the knob after dragging; * - When the user moves the knob with keyboard arrows * * This event will not emit when programmatically setting the `value` property. */ @Event() ionChange!: EventEmitter<RangeChangeEventDetail>; /** * The `ionInput` event is fired for `<ion-range>` elements when the value * is modified. Unlike `ionChange`, `ionInput` is fired continuously * while the user is dragging the knob. */ @Event() ionInput!: EventEmitter<RangeChangeEventDetail>; /** * Emitted when the range has focus. */ @Event() ionFocus!: EventEmitter<void>; /** * Emitted when the range loses focus. */ @Event() ionBlur!: EventEmitter<void>; /** * Emitted when the user starts moving the range knob, whether through * mouse drag, touch gesture, or keyboard interaction. */ @Event() ionKnobMoveStart!: EventEmitter<RangeKnobMoveStartEventDetail>; /** * Emitted when the user finishes moving the range knob, whether through * mouse drag, touch gesture, or keyboard interaction. */ @Event() ionKnobMoveEnd!: EventEmitter<RangeKnobMoveEndEventDetail>; private setupGesture = async () => { const rangeSlider = this.rangeSlider; if (rangeSlider) { this.gesture = (await import('../../utils/gesture')).createGesture({ el: rangeSlider, gestureName: 'range', gesturePriority: 100, /** * Provide a threshold since the drag movement * might be a user scrolling the view. * If this is true, then the range * should not move. */ threshold: 10, onStart: () => this.onStart(), onMove: (ev) => this.onMove(ev), onEnd: (ev) => this.onEnd(ev), }); this.gesture.enable(!this.disabled); } }; /** * Observes the knob handles for the ion-activated class and syncs * activatedKnob so the activated part is correctly set on the handle, * knob, and pin. */ private setupActivatedObserver = () => { const knobHandleA = this.el.shadowRoot!.querySelector('.range-knob-handle-a'); const knobHandleB = this.el.shadowRoot!.querySelector('.range-knob-handle-b'); const syncActivated = () => { this.activatedKnob = (knobHandleA as HTMLElement)?.classList.contains('ion-activated') ? 'A' : (knobHandleB as HTMLElement)?.classList.contains('ion-activated') ? 'B' : undefined; }; if (Build.isBrowser && typeof MutationObserver !== 'undefined') { this.activatedObserver = new MutationObserver(syncActivated); this.activatedObserver.observe(this.el.shadowRoot!, { attributes: true, attributeFilter: ['class'], subtree: true, }); } syncActivated(); }; componentWillLoad() { /** * If user has custom ID set then we should * not assign the default incrementing ID. */ if (this.el.hasAttribute('id')) { this.rangeId = this.el.getAttribute('id')!; } this.inheritedAttributes = inheritAriaAttributes(this.el); // If min, max, or step are not safe, set them to 0, 100, and 1, respectively. // Each watch does this, but not before the initial load. this.min = isSafeNumber(this.min) ? this.min : 0; this.max = isSafeNumber(this.max) ? this.max : 100; this.step = isSafeNumber(this.step) ? this.step : 1; } componentDidLoad() { this.originalIonInput = this.ionInput; this.setupGesture(); this.updateRatio(); this.setupActivatedObserver(); this.didLoad = true; } connectedCallback() { this.updateRatio(); this.debounceChanged(); this.disabledChanged(); this.activeBarStartChanged(); /** * 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(); this.setupActivatedObserver(); } const ionContent = findClosestIonContent(this.el); this.contentEl = ionContent?.querySelector('.ion-content-scroll-host') ?? ionContent; } disconnectedCallback() { if (this.gesture) { this.gesture.destroy(); this.gesture = undefined; } if (this.activatedObserver) { this.activatedObserver.disconnect(); this.activatedObserver = undefined; } } private handleKeyboard = (knob: KnobName, isIncrease: boolean) => { const { ensureValueInBounds } = this; 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.ionKnobMoveStart.emit({ value: ensureValueInBounds(this.value) }); this.updateValue(); this.emitValueChange(); this.ionKnobMoveEnd.emit({ value: ensureValueInBounds(this.value) }); }; 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; } } /** * Emits an `ionChange` event. * * This API should be called for user committed changes. * This API should not be used for external value changes. */ private emitValueChange() { this.value = this.ensureValueInBounds(this.value); this.ionChange.emit({ value: this.value }); } /** * The value should be updated on touch end or * when the component is being dragged. * This follows the native behavior of mobile devices. * * For example: When the user lifts their finger from the * screen after tapping the bar or dragging the bar or knob. */ private onStart() { this.ionKnobMoveStart.emit({ value: this.ensureValueInBounds(this.value) }); } /** * The value should be updated while dragging the * bar or knob. * * While the user is dragging, the view * should not scroll. This is to prevent the user from * feeling disoriented while dragging. * * The user can scroll on the view if the knob or * bar is not being dragged. * * @param detail The details of the gesture event. */ private onMove(detail: GestureDetail) { const { contentEl, pressedKnob } = this; const currentX = detail.currentX; /** * Since the user is dragging on the bar or knob, the view should not scroll. * * This only needs to be done once. */ if (contentEl && this.pressedKnob === undefined) { this.initialContentScrollY = disableContentScrollY(contentEl); } /** * The `pressedKnob` can be undefined if the user just * started dragging the knob. * * This is necessary to determine which knob the user is dragging, * especially when using dual knobs. * Plus, it determines when to apply certain styles. * * This only needs to be done once since the knob won't change * while the user is dragging. */ if (pressedKnob === undefined) { this.setPressedKnob(currentX); } this.update(currentX); } /** * The value should be updated on touch end: * - When the user lifts their finger from the screen after * tapping the bar. * * @param detail The details of the gesture or mouse event. */ private onEnd(detail: GestureDetail | MouseEvent) { const { contentEl, initialContentScrollY } = this; const currentX = (detail as GestureDetail).currentX ?? (detail as MouseEvent).clientX; /** * The `pressedKnob` can be undefined if the user never * dragged the knob. They just tapped on the bar. * * This is necessary to determine which knob the user is changing, * especially when using dual knobs. * Plus, it determines when to apply certain styles. */ if (this.pressedKnob === undefined) { this.setPressedKnob(currentX); } /** * The user is no longer dragging the bar or * knob (if they were dragging it). * * The user can now scroll on the view in the next gesture event. */ if (contentEl && this.pressedKnob !== undefined) { resetContentScrollY(contentEl, initialContentScrollY); } // update the active knob's position this.update(currentX); /** * Reset the pressed knob to undefined since the user * may start dragging a different knob in the next gesture event. */ this.pressedKnob = undefined; this.emitValueChange(); this.ionKnobMoveEnd.emit({ value: this.ensureValueInBounds(this.value) }); } 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 (isRTL(this.el)) { 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 setPressedKnob(currentX: number) { const rect = (this.rect = this.rangeSlider!.getBoundingClientRect() as any); // figure out which knob they started closer to let ratio = clamp(0, (currentX - rect.left) / rect.width, 1); if (isRTL(this.el)) { ratio = 1 - ratio; } this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B'; } 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); } const { activeBarStart } = this; if (activeBarStart == null) { return 0; } return valueToRatio(activeBarStart, this.min, this.max); } 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; /** * For dual knobs, value gives lower/upper but not which is A vs B. * Assign (lowerRatio, upperRatio) to (ratioA, ratioB) in the way that * minimizes change from the current ratios so the knobs don't swap. */ if (this.dualKnobs) { const lowerRatio = valueToRatio(value.lower, min, max); const upperRatio = valueToRatio(value.upper, min, max); if ( Math.abs(this.ratioA - lowerRatio) + Math.abs(this.ratioB - upperRatio) <= Math.abs(this.ratioA - upperRatio) + Math.abs(this.ratioB - lowerRatio) ) { this.ratioA = lowerRatio; this.ratioB = upperRatio; } else { this.ratioA = upperRatio; this.ratioB = lowerRatio; } } 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 onBlur = () => { if (this.hasFocus) { this.hasFocus = false; this.focusedKnob = undefined; this.ionBlur.emit(); } }; private onFocus = () => { if (!this.hasFocus) { this.hasFocus = true; this.ionFocus.emit(); } }; private onKnobFocus = (knob: KnobName) => { // Clicking focuses the range which is needed for the keyboard, // but we only want to add the ion-focused class when focused via Tab. if (!this.focusFromPointer) { this.focusedKnob = knob; } else { this.focusFromPointer = false; this.focusedKnob = undefined; } // If the knob was not already focused, emit the focus event if (!this.hasFocus) { this.hasFocus = true; this.ionFocus.emit(); } }; private onKnobBlur = () => { // Check if focus is moving to another knob within the same range // by delaying the reset to allow the new focus to register setTimeout(() => { const activeElement = this.el.shadowRoot?.activeElement; const isStillFocusedOnKnob = activeElement && activeElement.classList.contains('range-knob-handle'); if (!isStillFocusedOnKnob) { if (this.hasFocus) { this.hasFocus = false; this.focusedKnob = undefined; this.ionBlur.emit(); } } }, 0); }; private onKnobMouseEnter = (knob: KnobName) => { this.hoveredKnob = knob; }; private onKnobMouseLeave = () => { this.hoveredKnob = undefined; }; /** * Returns true if content was passed to the "start" slot */ private get hasStartSlotContent() { return this.el.querySelector('[slot="start"]') !== null; } /** * Returns true if content was passed to the "end" slot */ private get hasEndSlotContent() { return this.el.querySelector('[slot="end"]') !== null; } private get hasLabel() { return this.label !== undefined || this.el.querySelector('[slot="label"]') !== null; } private renderRangeSlider() { const { min, max, step, handleKeyboard, activatedKnob, focusedKnob, hoveredKnob, pressedKnob, disabled, pin, ratioLower, ratioUpper, pinFormatter, inheritedAttributes, } = this; let barStart = `${ratioLower * 100}%`; let barEnd = `${100 - ratioUpper * 100}%`; const rtl = isRTL(this.el); const start = rtl ? 'right' : 'left'; const end = rtl ? 'left' : 'right'; const tickStyle = (tick: any) => { return { [start]: tick[start], }; }; if (this.dualKnobs === false) { /** * When the value is less than the activeBarStart or the min value, * the knob will display at the start of the active bar. */ if (this.valA < (this.activeBarStart ?? this.min)) { /** * Sets the bar positions relative to the upper and lower limits. * Converts the ratio values into percentages, used as offsets for left/right styles. * * The ratioUpper refers to the knob position on the bar. * The ratioLower refers to the end position of the active bar (the value). */ barStart = `${ratioUpper * 100}%`; barEnd = `${100 - ratioLower * 100}%`; } else { /** * Otherwise, the knob will display at the end of the active bar. * * The ratioLower refers to the start position of the active bar (the value). * The ratioUpper refers to the knob position on the bar. */ barStart = `${ratioLower * 100}%`; barEnd = `${100 - ratioUpper * 100}%`; } } 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 ratioMin = Math.min(ratioLower, ratioUpper); const ratioMax = Math.max(ratioLower, ratioUpper); const tick: any = { ratio, /** * Sets the tick mark as active when the tick is between the min bounds and the knob. * When using activeBarStart, the tick mark will be active between the knob and activeBarStart. */ active: ratio >= ratioMin && ratio <= ratioMax, }; tick[start] = `${ratio * 100}%`; ticks.push(tick); } } return ( <div class="range-slider" ref={(rangeEl) => (this.rangeSlider = rangeEl)} onPointerDown={() => { this.focusFromPointer = true; }} /** * Since the gesture has a threshold, the value * won't change until the user has dragged past * the threshold. This is to prevent the range * from moving when the user is scrolling. * * This results in the value not being updated * and the event emitters not being triggered * if the user taps on the range. This is why * we need to listen for the "pointerUp" event. */ onPointerUp={(ev: PointerEvent) => { this.focusFromPointer = false; /** * If the user drags the knob on the web * version (does not occur on mobile), * the "pointerUp" event will be triggered * along with the gesture's events. * This leads to duplicate events. * * By checking if the pressedKnob is undefined, * we can determine if the "pointerUp" event was * triggered by a tap or a drag. If it was * dragged, the pressedKnob will be defined. */ if (this.pressedKnob === undefined) { this.onStart(); this.onEnd(ev); } }} > {ticks.map((tick) => ( <div style={tickStyle(tick)} role="presentation" class={{ 'range-tick': true, 'range-tick-active': tick.active, }} part={tick.active ? 'tick-active' : 'tick'} /> ))} <div class="range-bar-container"> <div class="range-bar" role="presentation" part="bar" /> <div class={{ 'range-bar': true, 'range-bar-active': true, 'has-ticks': ticks.length > 0, }} role="presentation" style={barStyle} part="bar-active" /> </div> {renderKnob(rtl, { knob: 'A', position: getKnobPosition('A', this.ratioA, this.ratioB, this.dualKnobs), dualKnobs: this.dualKnobs, activated: activatedKnob === 'A', focused: focusedKnob === 'A', hovered: hoveredKnob === 'A', pressed: pressedKnob === 'A', value: this.valA, ratio: this.ratioA, pin, pinFormatter, disabled, handleKeyboard, min, max, inheritedAttributes, onKnobFocus: this.onKnobFocus, onKnobBlur: this.onKnobBlur, onKnobMouseEnter: this.onKnobMouseEnter, onKnobMouseLeave: this.onKnobMouseLeave, })} {this.dualKnobs && renderKnob(rtl, { knob: 'B', position: getKnobPosition('B', this.ratioA, this.ratioB, this.dualKnobs), dualKnobs: this.dualKnobs, activated: activatedKnob === 'B', focused: focusedKnob === 'B', hovered: hoveredKnob === 'B', pressed: pressedKnob === 'B', value: this.valB, ratio: this.ratioB, pin, pinFormatter, disabled, handleKeyboard, min, max, inheritedAttributes, onKnobFocus: this.onKnobFocus, onKnobBlur: this.onKnobBlur, onKnobMouseEnter: this.onKnobMouseEnter, onKnobMouseLeave: this.onKnobMouseLeave, })} </div> ); } render() { const { disabled, el, hasLabel, rangeId, pin, pressedKnob, labelPlacement, label, dualKnobs, min, max } = this; const inItem = hostContext('ion-item', el); /** * If there is no start content then the knob at * the min value will be cut off by the item margin. */ const hasStartContent = (hasLabel && (labelPlacement === 'start' || labelPlacement === 'fixed')) || this.hasStartSlotContent; const needsStartAdjustment = inItem && !hasStartContent; /** * If there is no end content then the knob at * the max value will be cut off by the item margin. */ const hasEndContent = (hasLabel && labelPlacement === 'end') || this.hasEndSlotContent; const needsEndAdjustment = inItem && !hasEndContent; const mode = getIonMode(this); /** * Determine the name and position of the pressed knob to apply * Host classes for styling. */ const pressedKnobName = dualKnobs ? pressedKnob?.toLowerCase() : undefined; const pressedKnobPosition = dualKnobs && pressedKnob ? getKnobPosition(pressedKnob, this.ratioA, this.ratioB, dualKnobs) : undefined; /** * Determine if any knob is at the min or max value to * apply Host classes for styling. */ const valueAtMin = dualKnobs ? this.valA === min || this.valB === min : this.valA === min; const valueAtMax = dualKnobs ? this.valA === max || this.valB === max : this.valA === max; renderHiddenInput(true, el, this.name, JSON.stringify(this.getValue()), disabled); return ( <Host onFocusin={this.onFocus} onFocusout={this.onBlur} id={rangeId} class={createColorClasses(this.color, { [mode]: true, 'in-item': inItem, 'range-disabled': disabled, 'range-dual-knobs': dualKnobs, 'range-pressed': pressedKnob !== undefined, [`range-pressed-${pressedKnobName}`]: pressedKnob !== undefined && pressedKnobName !== undefined, [`range-pressed-${pressedKnobPosition}`]: pressedKnob !== undefined && pressedKnobPosition !== undefined, 'range-has-pin': pin, [`range-label-placement-${labelPlacement}`]: true, 'range-item-start-adjustment': needsStartAdjustment, 'range-item-end-adjustment': needsEndAdjustment, 'range-value-min': valueAtMin, 'range-value-max': valueAtMax, })} > <label class="range-wrapper" id="range-label"> <div class={{ 'label-text-wrapper': true, 'label-text-wrapper-hidden': !hasLabel, }} part="label" > {label !== undefined ? <div class="label-text">{label}</div> : <slot name="label"></slot>} </div> <div class="native-wrapper"> <slot name="start"></slot> {this.renderRangeSlider()} <slot name="end"></slot> </div> </label> </Host> ); } } interface RangeKnob { knob: KnobName; position: KnobPosition; dualKnobs: boolean; value: number; ratio: number; min: number; max: number; disabled: boolean; pressed: boolean; focused: boolean; hovered: boolean; activated: boolean; pin: boolean; pinFormatter: PinFormatter; inheritedAttributes: Attributes; handleKeyboard: (name: KnobName, isIncrease: boolean) => void; onKnobFocus: (knob: KnobName) => void; onKnobBlur: () => void; onKnobMouseEnter: (knob: KnobName) => void; onKnobMouseLeave: () => void; } const renderKnob = ( rtl: boolean, { knob, position, dualKnobs, value, ratio, min, max, disabled, activated, focused, hovered, pressed, pin, handleKeyboard, pinFormatter, inheritedAttributes, onKnobFocus, onKnobBlur, onKnobMouseEnter, onKnobMouseLeave, }: RangeKnob ) => { const start = rtl ? 'right' : 'left'; const knobStyle = () => { const style: any = {}; style[start] = `${ratio * 100}%`; return style; }; // The aria label should be preferred over visible text if both are specified const ariaLabel = inheritedAttributes['aria-label']; return ( <div onKeyDown={(ev: KeyboardEvent) => { 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(); } }} onFocus={() => onKnobFocus(knob)} onBlur={onKnobBlur} onMouseEnter={() => onKnobMouseEnter(knob)} onMouseLeave={onKnobMouseLeave} class={{ 'range-knob-handle': true, 'range-knob-handle-a': knob === 'A', 'range-knob-handle-b': knob === 'B', 'range-knob-pressed': pressed, 'range-knob-min': value === min, 'range-knob-max': value === max, 'ion-activatable': true, 'ion-focusable': true, 'ion-focused': focused, }} part={[ 'knob-handle', dualKnobs && knob === 'A' && 'knob-handle-a', dualKnobs && knob === 'B' && 'knob-handle-b', dualKnobs && position === 'lower' && 'knob-handle-lower', dualKnobs && position === 'upper' && 'knob-handle-upper', pressed && 'pressed', focused && 'focused', hovered && 'hover', activated && 'activated', ] .filter(Boolean) .join(' ')} style={knobStyle()} role="slider" tabindex={disabled ? -1 : 0} aria-label={ariaLabel !== undefined ? ariaLabel : null} aria-labelledby={ariaLabel === undefined ? 'range-label' : null} aria-valuemin={min} aria-valuemax={max} aria-disabled={disabled ? 'true' : null} aria-valuenow={value} > {pin && ( <div class="range-pin" role="presentation" part={[ 'pin', dualKnobs && knob === 'A' && 'pin-a', dualKnobs && knob === 'B' && 'pin-b', dualKnobs && position === 'lower' && 'pin-lower', dualKnobs && position === 'upper' && 'pin-upper', pressed && 'pressed', focused && 'focused', hovered && 'hover', activated && 'activated', ] .filter(Boolean) .join(' ')} > {pinFormatter(value)} </div> )} <div class="range-knob" role="presentation" part={[ 'knob', dualKnobs && knob === 'A' && 'knob-a', dualKnobs && knob === 'B' && 'knob-b', dualKnobs && position === 'lower' && 'knob-lower', dualKnobs && position === 'upper' && 'knob-upper', pressed && 'pressed', focused && 'focused', hovered && 'hover', activated && 'activated', ] .filter(Boolean) .join(' ')} /> </div> ); }; /** * Returns whether the given knob is at the lower or upper position based * on current ratios for the given knob. * * When both knobs have the same ratio, we only want one "lower" and one * "upper" position so that the `lower` and `upper` parts are not applied to * the same knob. In that case, we treat knob "A" as the lower position and * knob "B" as the upper position. */ const getKnobPosition = (knob: 'A' | 'B', ratioA: number, ratioB: number, dualKnobs: boolean): 'lower' | 'upper' => { if (!dualKnobs) { return 'lower'; } if (ratioA === ratioB) { return knob === 'A' ? 'lower' : 'upper'; } if (knob === 'A') { return ratioA < ratioB ? 'lower' : 'upper'; } return ratioB < ratioA ? 'lower' : 'upper'; }; const ratioToValue = (ratio: number, min: number, max: number, step: number): number => { let value = (max - min) * ratio; if (step > 0) { // round to nearest multiple of step, then add min value = Math.round(value / step) * step + min; } const clampedValue = clamp(min, value, max); return roundToMaxDecimalPlaces(clampedValue, min, max, step); }; const valueToRatio = (value: number, min: number, max: number): number => { return clamp(0, (value - min) / (max - min), 1); }; let rangeIds = 0;