Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ef2eb94
feat(range): add knob parts for A and B when dualKnobs is enabled
brandyscarney Jan 27, 2026
915bf05
test(range): update references to old knob handle class
brandyscarney Jan 27, 2026
67f605b
test(range): add spec test for new css class and parts
brandyscarney Jan 27, 2026
f57efc4
feat(range): add pin a and b parts
brandyscarney Jan 27, 2026
83803c6
feat(range): add pressed and focused parts for knob-handle, knob and pin
brandyscarney Jan 28, 2026
d5b2570
test(range): add pressed and focused parts to spec test
brandyscarney Jan 29, 2026
6741093
test(range): update custom test to check styles instead of screenshots
brandyscarney Jan 29, 2026
ac39d58
Merge branch 'feature-8.8' into FW-6582
brandyscarney Jan 29, 2026
a305510
test(range): target knob by handle a or b
brandyscarney Jan 30, 2026
e617be3
feat(range): add upper and lower knob parts and fix related focus bugs
brandyscarney Jan 30, 2026
7924d58
fix(range): add focus back to the range for keyboard movements
brandyscarney Jan 30, 2026
6c5bfac
test(range): update custom test to include lower/upper parts
brandyscarney Jan 30, 2026
f08715b
feat(range): add hover part for knob-handle, knob and pin
brandyscarney Feb 3, 2026
bfcd5e5
test(range): add hover styles to custom test
brandyscarney Feb 3, 2026
64c2fa2
test(range): update custom to have different hover styles
brandyscarney Feb 3, 2026
0a920f7
Merge branch 'feature-8.8' into FW-6582
brandyscarney Feb 4, 2026
6fc5e15
feat(range): apply range-pressed classes to host based on the knob
brandyscarney Feb 20, 2026
9964121
test(range): add test for range-pressed classes
brandyscarney Feb 20, 2026
4f4c2cf
feat(range): add the activated part for handle, knob and pin
brandyscarney Feb 20, 2026
d63cdd6
fix(range): check for MutationObserver
brandyscarney Feb 23, 2026
8522c1a
test(range): clean up duplicated part checks
brandyscarney Feb 23, 2026
524469f
test(range): add tests for the dual knob lower and upper parts, restr…
brandyscarney Feb 23, 2026
4fb4aca
test(range): add a test for swapping the knobs
brandyscarney Feb 23, 2026
d3b2332
test(range): add check for label part
brandyscarney Feb 23, 2026
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
9 changes: 9 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1474,9 +1474,18 @@ 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,label
ion-range,part,pin
ion-range,part,pin-a
ion-range,part,pin-b
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
88 changes: 74 additions & 14 deletions core/src/components/range/range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,22 @@ 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 lower/left knob. Only available when `dualKnobs` is `true`.
* @part knob-handle-b - The container element for the upper/right 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 lower/left knob. Only available when `dualKnobs` is `true`.
* @part pin-b - The counter that appears above the upper/right 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 lower/left knob. Only available when `dualKnobs` is `true`.
* @part knob-b - The visual knob element for the upper/right 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 Down Expand Up @@ -63,6 +72,7 @@ export class Range implements ComponentInterface {
@State() private ratioA = 0;
@State() private ratioB = 0;
@State() private pressedKnob: KnobName;
@State() private focusedKnob: KnobName | undefined;

/**
* The color to use from your application's color palette.
Expand Down Expand Up @@ -616,9 +626,9 @@ export class Range implements ComponentInterface {

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;
const knobEl = this.el.shadowRoot.querySelector(
knob === 'A' ? '.range-knob-handle-a' : '.range-knob-handle-b'
) as HTMLElement | undefined;
if (knobEl) {
knobEl.focus();
}
Expand All @@ -628,6 +638,7 @@ export class Range implements ComponentInterface {
private onBlur = () => {
if (this.hasFocus) {
this.hasFocus = false;
this.focusedKnob = undefined;
this.ionBlur.emit();
}
};
Expand All @@ -640,15 +651,16 @@ export class Range implements ComponentInterface {
};

private onKnobFocus = (knob: KnobName) => {
this.focusedKnob = knob;
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');
const knobA = this.el.shadowRoot.querySelector('.range-knob-handle-a');
const knobB = this.el.shadowRoot.querySelector('.range-knob-handle-b');

// Remove ion-focused from both knobs first
knobA?.classList.remove('ion-focused');
Expand All @@ -670,13 +682,14 @@ 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');
const knobA = this.el.shadowRoot.querySelector('.range-knob-handle-a');
const knobB = this.el.shadowRoot.querySelector('.range-knob-handle-b');
knobA?.classList.remove('ion-focused');
knobB?.classList.remove('ion-focused');
}
Expand Down Expand Up @@ -708,6 +721,7 @@ export class Range implements ComponentInterface {
max,
step,
handleKeyboard,
focusedKnob,
pressedKnob,
disabled,
pin,
Expand Down Expand Up @@ -848,7 +862,9 @@ export class Range implements ComponentInterface {

{renderKnob(rtl, {
knob: 'A',
dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'A',
focused: focusedKnob === 'A',
value: this.valA,
ratio: this.ratioA,
pin,
Expand All @@ -865,7 +881,9 @@ export class Range implements ComponentInterface {
{this.dualKnobs &&
renderKnob(rtl, {
knob: 'B',
dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'B',
focused: focusedKnob === 'B',
value: this.valB,
ratio: this.ratioB,
pin,
Expand Down Expand Up @@ -924,6 +942,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 +975,14 @@ export class Range implements ComponentInterface {

interface RangeKnob {
knob: KnobName;
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 +995,14 @@ const renderKnob = (
rtl: boolean,
{
knob,
dualKnobs,
value,
ratio,
min,
max,
disabled,
pressed,
focused,
pin,
handleKeyboard,
pinFormatter,
Expand Down Expand Up @@ -1019,14 +1042,23 @@ 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,
}}
part={[
'knob-handle',
dualKnobs && knob === 'A' && 'knob-handle-a',
dualKnobs && knob === 'B' && 'knob-handle-b',
pressed && 'pressed',
focused && 'focused',
]
.filter(Boolean)
.join(' ')}
style={knobStyle()}
role="slider"
tabindex={disabled ? -1 : 0}
Expand All @@ -1038,11 +1070,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',
pressed && 'pressed',
focused && 'focused',
]
.filter(Boolean)
.join(' ')}
>
{pinFormatter(value)}
</div>
)}
<div class="range-knob" role="presentation" part="knob" />
<div
class={{
'range-knob': true,
'range-knob-a': knob === 'A',
'range-knob-b': knob === 'B',
}}
role="presentation"
part={[
'knob',
dualKnobs && knob === 'A' && 'knob-a',
dualKnobs && knob === 'B' && 'knob-b',
pressed && 'pressed',
focused && 'focused',
]
.filter(Boolean)
.join(' ')}
/>
</div>
);
};
Expand Down
22 changes: 11 additions & 11 deletions core/src/components/range/test/basic/range.spec.ts
Copy link
Member Author

Choose a reason for hiding this comment

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

I renamed the classes assigned to knob-handle from range-knob-a to range-knob-handle-a to match what they are actually applied to.

Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ describe('range: dual knobs focus management', () => {
await page.waitForChanges();

// Get the knob elements
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;

expect(knobA).not.toBeNull();
expect(knobB).not.toBeNull();
Expand All @@ -41,8 +41,8 @@ describe('range: dual knobs focus management', () => {
const range = page.body.querySelector('ion-range');
await page.waitForChanges();

const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
Expand Down Expand Up @@ -73,8 +73,8 @@ describe('range: dual knobs focus management', () => {
const range = page.body.querySelector('ion-range');
await page.waitForChanges();

const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range!.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range!.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
Expand Down Expand Up @@ -112,8 +112,8 @@ describe('range: dual knobs focus management', () => {
focusEventFiredCount++;
});

const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
Expand All @@ -140,7 +140,7 @@ describe('range: dual knobs focus management', () => {
blurEventFired = true;
});

const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;

// Focus and then blur knob A
knobA.dispatchEvent(new Event('focus'));
Expand Down Expand Up @@ -173,8 +173,8 @@ describe('range: dual knobs focus management', () => {
const beforeButton = page.body.querySelector('#before') as HTMLElement;
await page.waitForChanges();

const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
const knobA = range.shadowRoot!.querySelector('.range-knob-handle-a') as HTMLElement;
const knobB = range.shadowRoot!.querySelector('.range-knob-handle-b') as HTMLElement;

// Start with focus on element before the range
beforeButton.focus();
Expand Down
Loading
Loading