Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion core/setupJest.js
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is required otherwise it won't match knob when it has knob knob-a.

Original file line number Diff line number Diff line change
Expand Up @@ -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}"`;
Expand Down
2 changes: 2 additions & 0 deletions core/src/components/range/range-interface.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
160 changes: 116 additions & 44 deletions core/src/components/range/range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { roundToMaxDecimalPlaces } from '../../utils/floating-point';

import type {
KnobName,
KnobPosition,
RangeChangeEventDetail,
RangeKnobMoveEndEventDetail,
RangeKnobMoveStartEventDetail,
Expand All @@ -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',
Expand All @@ -57,12 +73,14 @@ export class Range implements ComponentInterface {
private contentEl: HTMLElement | null = null;
private initialContentScrollY = true;
private originalIonInput?: EventEmitter<RangeChangeEventDetail>;
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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();
}
};
Expand All @@ -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 = () => {
Expand All @@ -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);
};
Expand Down Expand Up @@ -708,6 +721,7 @@ export class Range implements ComponentInterface {
max,
step,
handleKeyboard,
focusedKnob,
pressedKnob,
disabled,
pin,
Expand Down Expand Up @@ -790,6 +804,9 @@ export class Range implements ComponentInterface {
<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
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -974,12 +1003,15 @@ const renderKnob = (
rtl: boolean,
{
knob,
position,
dualKnobs,
value,
ratio,
min,
max,
disabled,
pressed,
focused,
pin,
handleKeyboard,
pinFormatter,
Expand Down Expand Up @@ -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}
Expand All @@ -1038,11 +1082,39 @@ const renderKnob = (
aria-valuenow={value}
>
{pin && (
<div class="range-pin" role="presentation" part="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',
]
.filter(Boolean)
.join(' ')}
>
{pinFormatter(value)}
</div>
)}
<div class="range-knob" role="presentation" part="knob" />
<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',
]
.filter(Boolean)
.join(' ')}
/>
</div>
);
};
Expand Down
Loading
Loading