diff --git a/core/api.txt b/core/api.txt index 995c372cdfd..686dd093016 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1475,9 +1475,24 @@ ion-range,css-prop,--pin-color,ios ion-range,css-prop,--pin-color,md ion-range,part,bar ion-range,part,bar-active +ion-range,part,focused ion-range,part,knob +ion-range,part,knob-a +ion-range,part,knob-b +ion-range,part,knob-handle +ion-range,part,knob-handle-a +ion-range,part,knob-handle-b +ion-range,part,knob-handle-lower +ion-range,part,knob-handle-upper +ion-range,part,knob-lower +ion-range,part,knob-upper ion-range,part,label ion-range,part,pin +ion-range,part,pin-a +ion-range,part,pin-b +ion-range,part,pin-lower +ion-range,part,pin-upper +ion-range,part,pressed ion-range,part,tick ion-range,part,tick-active diff --git a/core/setupJest.js b/core/setupJest.js index f2eb0e70a31..77ea127c680 100644 --- a/core/setupJest.js +++ b/core/setupJest.js @@ -8,7 +8,9 @@ expect.extend({ throw new Error('expected toHaveShadowPart to be called on an element with a shadow root'); } - const shadowPart = received.shadowRoot.querySelector(`[part="${part}"]`); + // Use attribute selector with ~= to match space-separated part values + // e.g., [part~="knob"] matches elements with part="knob" or part="knob knob-a" + const shadowPart = received.shadowRoot.querySelector(`[part~="${part}"]`); const pass = shadowPart !== null; const message = `expected ${received.tagName.toLowerCase()} to have shadow part "${part}"`; diff --git a/core/src/components/range/range-interface.ts b/core/src/components/range/range-interface.ts index 1908bbab172..9cc696d90e3 100644 --- a/core/src/components/range/range-interface.ts +++ b/core/src/components/range/range-interface.ts @@ -1,5 +1,7 @@ export type KnobName = 'A' | 'B' | undefined; +export type KnobPosition = 'lower' | 'upper' | undefined; + export type RangeValue = number | { lower: number; upper: number }; export type PinFormatter = (value: number) => number | string; diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx index 431dbe4b7fe..184d7c7e9b1 100644 --- a/core/src/components/range/range.tsx +++ b/core/src/components/range/range.tsx @@ -13,6 +13,7 @@ import { roundToMaxDecimalPlaces } from '../../utils/floating-point'; import type { KnobName, + KnobPosition, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, @@ -29,13 +30,28 @@ import type { * @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 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. - * @part label - The label text describing the range. + * @part knob-handle - The container element that wraps the knob and handles drag interactions. + * @part knob-handle-a - The container element for the first knob. Only available when `dualKnobs` is `true`. + * @part knob-handle-b - The container element for the second knob. Only available when `dualKnobs` is `true`. + * @part knob-handle-lower - The container element for the lower knob. Only available when `dualKnobs` is `true`. + * @part knob-handle-upper - The container element for the upper knob. Only available when `dualKnobs` is `true`. + * @part pin - The counter that appears above a knob. + * @part pin-a - The counter that appears above the first knob. Only available when `dualKnobs` is `true`. + * @part pin-b - The counter that appears above the second knob. Only available when `dualKnobs` is `true`. + * @part pin-lower - The counter that appears above the lower knob. Only available when `dualKnobs` is `true`. + * @part pin-upper - The counter that appears above the upper knob. Only available when `dualKnobs` is `true`. + * @part knob - The visual knob element that appears on the range track. + * @part knob-a - The visual knob element for the first knob. Only available when `dualKnobs` is `true`. + * @part knob-b - The visual knob element for the second knob. Only available when `dualKnobs` is `true`. + * @part knob-lower - The visual knob element for the lower knob. Only available when `dualKnobs` is `true`. + * @part knob-upper - The visual knob element for the upper knob. Only available 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. + * @part focused - Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time. */ @Component({ tag: 'ion-range', @@ -57,12 +73,14 @@ export class Range implements ComponentInterface { private contentEl: HTMLElement | null = null; private initialContentScrollY = true; private originalIonInput?: EventEmitter; + private focusFromPointer = false; @Element() el!: HTMLIonRangeElement; @State() private ratioA = 0; @State() private ratioB = 0; @State() private pressedKnob: KnobName; + @State() private focusedKnob: KnobName; /** * The color to use from your application's color palette. @@ -515,6 +533,7 @@ export class Range implements ComponentInterface { // 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. @@ -559,8 +578,6 @@ export class Range implements ComponentInterface { ratio = 1 - ratio; } this.pressedKnob = !this.dualKnobs || Math.abs(this.ratioA - ratio) < Math.abs(this.ratioB - ratio) ? 'A' : 'B'; - - this.setFocus(this.pressedKnob); } private get valA() { @@ -592,9 +609,26 @@ export class Range implements ComponentInterface { 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) { - this.ratioA = valueToRatio(value.lower, min, max); - this.ratioB = valueToRatio(value.upper, min, max); + 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); } @@ -614,20 +648,10 @@ export class Range implements ComponentInterface { 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(); - } - } - } - private onBlur = () => { if (this.hasFocus) { this.hasFocus = false; + this.focusedKnob = undefined; this.ionBlur.emit(); } }; @@ -640,24 +664,20 @@ export class Range implements ComponentInterface { }; 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(); } - - // Manually manage ion-focused class for dual knobs - if (this.dualKnobs && this.el.shadowRoot) { - const knobA = this.el.shadowRoot.querySelector('.range-knob-a'); - const knobB = this.el.shadowRoot.querySelector('.range-knob-b'); - - // Remove ion-focused from both knobs first - knobA?.classList.remove('ion-focused'); - knobB?.classList.remove('ion-focused'); - - // Add ion-focused only to the focused knob - const focusedKnobEl = knob === 'A' ? knobA : knobB; - focusedKnobEl?.classList.add('ion-focused'); - } }; private onKnobBlur = () => { @@ -670,16 +690,9 @@ export class Range implements ComponentInterface { if (!isStillFocusedOnKnob) { if (this.hasFocus) { this.hasFocus = false; + this.focusedKnob = undefined; this.ionBlur.emit(); } - - // Remove ion-focused from both knobs when focus leaves the range - if (this.dualKnobs && this.el.shadowRoot) { - const knobA = this.el.shadowRoot.querySelector('.range-knob-a'); - const knobB = this.el.shadowRoot.querySelector('.range-knob-b'); - knobA?.classList.remove('ion-focused'); - knobB?.classList.remove('ion-focused'); - } } }, 0); }; @@ -708,6 +721,7 @@ export class Range implements ComponentInterface { max, step, handleKeyboard, + focusedKnob, pressedKnob, disabled, pin, @@ -790,6 +804,9 @@ export class Range implements ComponentInterface {
(this.rangeSlider = rangeEl)} + onPointerDown={() => { + this.focusFromPointer = true; + }} /** * Since the gesture has a threshold, the value * won't change until the user has dragged past @@ -802,6 +819,8 @@ export class Range implements ComponentInterface { * 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), @@ -848,7 +867,10 @@ export class Range implements ComponentInterface { {renderKnob(rtl, { knob: 'A', + position: this.dualKnobs ? (this.ratioA <= this.ratioB ? 'lower' : 'upper') : 'lower', + dualKnobs: this.dualKnobs, pressed: pressedKnob === 'A', + focused: focusedKnob === 'A', value: this.valA, ratio: this.ratioA, pin, @@ -865,7 +887,10 @@ export class Range implements ComponentInterface { {this.dualKnobs && renderKnob(rtl, { knob: 'B', + position: this.ratioB <= this.ratioA ? 'lower' : 'upper', + dualKnobs: this.dualKnobs, pressed: pressedKnob === 'B', + focused: focusedKnob === 'B', value: this.valB, ratio: this.ratioB, pin, @@ -924,6 +949,7 @@ export class Range implements ComponentInterface { [mode]: true, 'in-item': inItem, 'range-disabled': disabled, + 'range-dual-knobs': dualKnobs, 'range-pressed': pressedKnob !== undefined, 'range-has-pin': pin, [`range-label-placement-${labelPlacement}`]: true, @@ -956,12 +982,15 @@ export class Range implements ComponentInterface { interface RangeKnob { knob: KnobName; + position: KnobPosition; + dualKnobs: boolean; value: number; ratio: number; min: number; max: number; disabled: boolean; pressed: boolean; + focused: boolean; pin: boolean; pinFormatter: PinFormatter; inheritedAttributes: Attributes; @@ -974,12 +1003,15 @@ const renderKnob = ( rtl: boolean, { knob, + position, + dualKnobs, value, ratio, min, max, disabled, pressed, + focused, pin, handleKeyboard, pinFormatter, @@ -1019,14 +1051,26 @@ const renderKnob = ( onBlur={onKnobBlur} class={{ 'range-knob-handle': true, - 'range-knob-a': knob === 'A', - 'range-knob-b': knob === 'B', + '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', + ] + .filter(Boolean) + .join(' ')} style={knobStyle()} role="slider" tabindex={disabled ? -1 : 0} @@ -1038,11 +1082,39 @@ const renderKnob = ( aria-valuenow={value} > {pin && ( -