Skip to content

Commit e617be3

Browse files
committed
feat(range): add upper and lower knob parts and fix related focus bugs
1 parent a305510 commit e617be3

3 files changed

Lines changed: 77 additions & 42 deletions

File tree

core/api.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,10 +1482,16 @@ ion-range,part,knob-b
14821482
ion-range,part,knob-handle
14831483
ion-range,part,knob-handle-a
14841484
ion-range,part,knob-handle-b
1485+
ion-range,part,knob-handle-lower
1486+
ion-range,part,knob-handle-upper
1487+
ion-range,part,knob-lower
1488+
ion-range,part,knob-upper
14851489
ion-range,part,label
14861490
ion-range,part,pin
14871491
ion-range,part,pin-a
14881492
ion-range,part,pin-b
1493+
ion-range,part,pin-lower
1494+
ion-range,part,pin-upper
14891495
ion-range,part,pressed
14901496
ion-range,part,tick
14911497
ion-range,part,tick-active

core/src/components/range/range-interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export type KnobName = 'A' | 'B' | undefined;
22

3+
export type KnobPosition = 'lower' | 'upper' | undefined;
4+
35
export type RangeValue = number | { lower: number; upper: number };
46

57
export type PinFormatter = (value: number) => number | string;

core/src/components/range/range.tsx

Lines changed: 69 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { roundToMaxDecimalPlaces } from '../../utils/floating-point';
1313

1414
import type {
1515
KnobName,
16+
KnobPosition,
1617
RangeChangeEventDetail,
1718
RangeKnobMoveEndEventDetail,
1819
RangeKnobMoveStartEventDetail,
@@ -35,14 +36,20 @@ import type {
3536
* @part bar - The inactive part of the bar.
3637
* @part bar-active - The active part of the bar.
3738
* @part knob-handle - The container element that wraps the knob and handles drag interactions.
38-
* @part knob-handle-a - The container element for the lower/left knob. Only available when `dualKnobs` is `true`.
39-
* @part knob-handle-b - The container element for the upper/right knob. Only available when `dualKnobs` is `true`.
39+
* @part knob-handle-a - The container element for the first knob. Only available when `dualKnobs` is `true`.
40+
* @part knob-handle-b - The container element for the second knob. Only available when `dualKnobs` is `true`.
41+
* @part knob-handle-lower - The container element for the lower knob. Only available when `dualKnobs` is `true`.
42+
* @part knob-handle-upper - The container element for the upper knob. Only available when `dualKnobs` is `true`.
4043
* @part pin - The counter that appears above a knob.
41-
* @part pin-a - The counter that appears above the lower/left knob. Only available when `dualKnobs` is `true`.
42-
* @part pin-b - The counter that appears above the upper/right knob. Only available when `dualKnobs` is `true`.
44+
* @part pin-a - The counter that appears above the first knob. Only available when `dualKnobs` is `true`.
45+
* @part pin-b - The counter that appears above the second knob. Only available when `dualKnobs` is `true`.
46+
* @part pin-lower - The counter that appears above the lower knob. Only available when `dualKnobs` is `true`.
47+
* @part pin-upper - The counter that appears above the upper knob. Only available when `dualKnobs` is `true`.
4348
* @part knob - The visual knob element that appears on the range track.
44-
* @part knob-a - The visual knob element for the lower/left knob. Only available when `dualKnobs` is `true`.
45-
* @part knob-b - The visual knob element for the upper/right knob. Only available when `dualKnobs` is `true`.
49+
* @part knob-a - The visual knob element for the first knob. Only available when `dualKnobs` is `true`.
50+
* @part knob-b - The visual knob element for the second knob. Only available when `dualKnobs` is `true`.
51+
* @part knob-lower - The visual knob element for the lower knob. Only available when `dualKnobs` is `true`.
52+
* @part knob-upper - The visual knob element for the upper knob. Only available when `dualKnobs` is `true`.
4653
* @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.
4754
* @part focused - Added to the knob-handle, knob, and pin that currently has focus. Only one set has this part at a time.
4855
*/
@@ -72,7 +79,7 @@ export class Range implements ComponentInterface {
7279
@State() private ratioA = 0;
7380
@State() private ratioB = 0;
7481
@State() private pressedKnob: KnobName;
75-
@State() private focusedKnob: KnobName | undefined;
82+
@State() private focusedKnob: KnobName;
7683

7784
/**
7885
* The color to use from your application's color palette.
@@ -525,6 +532,13 @@ export class Range implements ComponentInterface {
525532

526533
// update the active knob's position
527534
this.update(currentX);
535+
536+
/**
537+
* Blur the knob so focus styles are cleared.
538+
*/
539+
const knobEl = this.getKnobElement(this.pressedKnob);
540+
knobEl?.blur();
541+
528542
/**
529543
* Reset the pressed knob to undefined since the user
530544
* may start dragging a different knob in the next gesture event.
@@ -573,6 +587,15 @@ export class Range implements ComponentInterface {
573587
this.setFocus(this.pressedKnob);
574588
}
575589

590+
/**
591+
* Returns the DOM element for the given knob.
592+
*/
593+
private getKnobElement(knob: KnobName): HTMLElement | null {
594+
return this.el.shadowRoot?.querySelector(
595+
knob === 'A' ? '.range-knob-handle-a' : '.range-knob-handle-b'
596+
) as HTMLElement | null;
597+
}
598+
576599
private get valA() {
577600
return ratioToValue(this.ratioA, this.min, this.max, this.step);
578601
}
@@ -602,9 +625,26 @@ export class Range implements ComponentInterface {
602625
private updateRatio() {
603626
const value = this.getValue() as any;
604627
const { min, max } = this;
628+
629+
/**
630+
* For dual knobs, value gives lower/upper but not which is A vs B.
631+
* Assign (lowerRatio, upperRatio) to (ratioA, ratioB) in the way that
632+
* minimizes change from the current ratios so the knobs don't swap.
633+
*/
605634
if (this.dualKnobs) {
606-
this.ratioA = valueToRatio(value.lower, min, max);
607-
this.ratioB = valueToRatio(value.upper, min, max);
635+
const lowerRatio = valueToRatio(value.lower, min, max);
636+
const upperRatio = valueToRatio(value.upper, min, max);
637+
638+
if (
639+
Math.abs(this.ratioA - lowerRatio) + Math.abs(this.ratioB - upperRatio) <=
640+
Math.abs(this.ratioA - upperRatio) + Math.abs(this.ratioB - lowerRatio)
641+
) {
642+
this.ratioA = lowerRatio;
643+
this.ratioB = upperRatio;
644+
} else {
645+
this.ratioA = upperRatio;
646+
this.ratioB = lowerRatio;
647+
}
608648
} else {
609649
this.ratioA = valueToRatio(value, min, max);
610650
}
@@ -626,12 +666,8 @@ export class Range implements ComponentInterface {
626666

627667
private setFocus(knob: KnobName) {
628668
if (this.el.shadowRoot) {
629-
const knobEl = this.el.shadowRoot.querySelector(
630-
knob === 'A' ? '.range-knob-handle-a' : '.range-knob-handle-b'
631-
) as HTMLElement | undefined;
632-
if (knobEl) {
633-
knobEl.focus();
634-
}
669+
const knobEl = this.getKnobElement(knob);
670+
knobEl?.focus();
635671
}
636672
}
637673

@@ -656,20 +692,6 @@ export class Range implements ComponentInterface {
656692
this.hasFocus = true;
657693
this.ionFocus.emit();
658694
}
659-
660-
// Manually manage ion-focused class for dual knobs
661-
if (this.dualKnobs && this.el.shadowRoot) {
662-
const knobA = this.el.shadowRoot.querySelector('.range-knob-handle-a');
663-
const knobB = this.el.shadowRoot.querySelector('.range-knob-handle-b');
664-
665-
// Remove ion-focused from both knobs first
666-
knobA?.classList.remove('ion-focused');
667-
knobB?.classList.remove('ion-focused');
668-
669-
// Add ion-focused only to the focused knob
670-
const focusedKnobEl = knob === 'A' ? knobA : knobB;
671-
focusedKnobEl?.classList.add('ion-focused');
672-
}
673695
};
674696

675697
private onKnobBlur = () => {
@@ -685,14 +707,6 @@ export class Range implements ComponentInterface {
685707
this.focusedKnob = undefined;
686708
this.ionBlur.emit();
687709
}
688-
689-
// Remove ion-focused from both knobs when focus leaves the range
690-
if (this.dualKnobs && this.el.shadowRoot) {
691-
const knobA = this.el.shadowRoot.querySelector('.range-knob-handle-a');
692-
const knobB = this.el.shadowRoot.querySelector('.range-knob-handle-b');
693-
knobA?.classList.remove('ion-focused');
694-
knobB?.classList.remove('ion-focused');
695-
}
696710
}
697711
}, 0);
698712
};
@@ -862,6 +876,7 @@ export class Range implements ComponentInterface {
862876

863877
{renderKnob(rtl, {
864878
knob: 'A',
879+
position: this.dualKnobs ? (this.ratioA <= this.ratioB ? 'lower' : 'upper') : 'lower',
865880
dualKnobs: this.dualKnobs,
866881
pressed: pressedKnob === 'A',
867882
focused: focusedKnob === 'A',
@@ -881,6 +896,7 @@ export class Range implements ComponentInterface {
881896
{this.dualKnobs &&
882897
renderKnob(rtl, {
883898
knob: 'B',
899+
position: this.ratioB <= this.ratioA ? 'lower' : 'upper',
884900
dualKnobs: this.dualKnobs,
885901
pressed: pressedKnob === 'B',
886902
focused: focusedKnob === 'B',
@@ -975,6 +991,7 @@ export class Range implements ComponentInterface {
975991

976992
interface RangeKnob {
977993
knob: KnobName;
994+
position: KnobPosition;
978995
dualKnobs: boolean;
979996
value: number;
980997
ratio: number;
@@ -995,6 +1012,7 @@ const renderKnob = (
9951012
rtl: boolean,
9961013
{
9971014
knob,
1015+
position,
9981016
dualKnobs,
9991017
value,
10001018
ratio,
@@ -1026,6 +1044,12 @@ const renderKnob = (
10261044

10271045
return (
10281046
<div
1047+
onMouseDown={(ev) => {
1048+
/**
1049+
* Prevent the knob from being focused when the user clicks on it.
1050+
*/
1051+
ev.preventDefault();
1052+
}}
10291053
onKeyDown={(ev: KeyboardEvent) => {
10301054
const key = ev.key;
10311055
if (key === 'ArrowLeft' || key === 'ArrowDown') {
@@ -1049,11 +1073,14 @@ const renderKnob = (
10491073
'range-knob-max': value === max,
10501074
'ion-activatable': true,
10511075
'ion-focusable': true,
1076+
'ion-focused': focused,
10521077
}}
10531078
part={[
10541079
'knob-handle',
10551080
dualKnobs && knob === 'A' && 'knob-handle-a',
10561081
dualKnobs && knob === 'B' && 'knob-handle-b',
1082+
dualKnobs && position === 'lower' && 'knob-handle-lower',
1083+
dualKnobs && position === 'upper' && 'knob-handle-upper',
10571084
pressed && 'pressed',
10581085
focused && 'focused',
10591086
]
@@ -1077,6 +1104,8 @@ const renderKnob = (
10771104
'pin',
10781105
dualKnobs && knob === 'A' && 'pin-a',
10791106
dualKnobs && knob === 'B' && 'pin-b',
1107+
dualKnobs && position === 'lower' && 'pin-lower',
1108+
dualKnobs && position === 'upper' && 'pin-upper',
10801109
pressed && 'pressed',
10811110
focused && 'focused',
10821111
]
@@ -1087,16 +1116,14 @@ const renderKnob = (
10871116
</div>
10881117
)}
10891118
<div
1090-
class={{
1091-
'range-knob': true,
1092-
'range-knob-a': knob === 'A',
1093-
'range-knob-b': knob === 'B',
1094-
}}
1119+
class="range-knob"
10951120
role="presentation"
10961121
part={[
10971122
'knob',
10981123
dualKnobs && knob === 'A' && 'knob-a',
10991124
dualKnobs && knob === 'B' && 'knob-b',
1125+
dualKnobs && position === 'lower' && 'knob-lower',
1126+
dualKnobs && position === 'upper' && 'knob-upper',
11001127
pressed && 'pressed',
11011128
focused && 'focused',
11021129
]

0 commit comments

Comments
 (0)