From ef2eb94801e4bf7f60873a46dac2b0c256ab57ee Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Tue, 27 Jan 2026 15:38:33 -0500
Subject: [PATCH 01/11] feat(range): add knob parts for A and B when dualKnobs
is enabled
---
core/api.txt | 5 ++++
core/src/components/range/range.tsx | 41 +++++++++++++++++++++--------
2 files changed, 35 insertions(+), 11 deletions(-)
diff --git a/core/api.txt b/core/api.txt
index 745d82786af..2ddaf648e63 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -1475,6 +1475,11 @@ ion-range,css-prop,--pin-color,md
ion-range,part,bar
ion-range,part,bar-active
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,tick
diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx
index 431dbe4b7fe..c9bf8d983b2 100644
--- a/core/src/components/range/range.tsx
+++ b/core/src/components/range/range.tsx
@@ -32,7 +32,12 @@ import type {
* @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 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 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 bar - The inactive part of the bar.
* @part bar-active - The active part of the bar.
* @part label - The label text describing the range.
@@ -616,9 +621,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();
}
@@ -647,8 +652,8 @@ export class Range implements ComponentInterface {
// 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');
@@ -675,8 +680,8 @@ export class Range implements ComponentInterface {
// 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');
}
@@ -848,6 +853,7 @@ export class Range implements ComponentInterface {
{renderKnob(rtl, {
knob: 'A',
+ dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'A',
value: this.valA,
ratio: this.ratioA,
@@ -865,6 +871,7 @@ export class Range implements ComponentInterface {
{this.dualKnobs &&
renderKnob(rtl, {
knob: 'B',
+ dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'B',
value: this.valB,
ratio: this.ratioB,
@@ -924,6 +931,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,6 +964,7 @@ export class Range implements ComponentInterface {
interface RangeKnob {
knob: KnobName;
+ dualKnobs: boolean;
value: number;
ratio: number;
min: number;
@@ -974,6 +983,7 @@ const renderKnob = (
rtl: boolean,
{
knob,
+ dualKnobs,
value,
ratio,
min,
@@ -1019,14 +1029,15 @@ 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={dualKnobs ? (knob === 'A' ? 'knob-handle knob-handle-a' : 'knob-handle knob-handle-b') : 'knob-handle'}
style={knobStyle()}
role="slider"
tabindex={disabled ? -1 : 0}
@@ -1042,7 +1053,15 @@ const renderKnob = (
{pinFormatter(value)}
)}
-
+
);
};
From 915bf05f3a625105a20bab34369df873469c2268 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Tue, 27 Jan 2026 15:39:13 -0500
Subject: [PATCH 02/11] test(range): update references to old knob handle class
---
.../components/range/test/basic/range.spec.ts | 22 +++++++++----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/core/src/components/range/test/basic/range.spec.ts b/core/src/components/range/test/basic/range.spec.ts
index ab53ee05dc2..1501ed00c6e 100644
--- a/core/src/components/range/test/basic/range.spec.ts
+++ b/core/src/components/range/test/basic/range.spec.ts
@@ -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();
@@ -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'));
@@ -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'));
@@ -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'));
@@ -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'));
@@ -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();
From 67f605ba4f45be07cda1ad4d5ebda30f0e9e68f2 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Tue, 27 Jan 2026 15:40:59 -0500
Subject: [PATCH 03/11] test(range): add spec test for new css class and parts
---
core/setupJest.js | 4 +-
core/src/components/range/test/range.spec.ts | 184 +++++++++++++++++--
2 files changed, 167 insertions(+), 21 deletions(-)
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/test/range.spec.ts b/core/src/components/range/test/range.spec.ts
index 33c9b0021d1..5d284935cb4 100644
--- a/core/src/components/range/test/range.spec.ts
+++ b/core/src/components/range/test/range.spec.ts
@@ -5,7 +5,7 @@ import { Range } from '../range';
let sharedRange: Range;
-describe('Range', () => {
+describe('range: values', () => {
beforeEach(() => {
sharedRange = new Range();
});
@@ -87,7 +87,7 @@ describe('Range', () => {
});
});
-describe('range id', () => {
+describe('range: id', () => {
it('should render custom id if passed', async () => {
const page = await newSpecPage({
components: [Range],
@@ -234,24 +234,6 @@ describe('range: item adjustments', () => {
expect(range.classList.contains('range-item-start-adjustment')).toBe(false);
expect(range.classList.contains('range-item-end-adjustment')).toBe(false);
});
-
- describe('shadow parts', () => {
- it('should have shadow parts', async () => {
- const page = await newSpecPage({
- components: [Range],
- html: ``,
- });
- const range = page.body.querySelector('ion-range')!;
-
- expect(range).toHaveShadowPart('label');
- expect(range).toHaveShadowPart('pin');
- expect(range).toHaveShadowPart('knob');
- expect(range).toHaveShadowPart('bar');
- expect(range).toHaveShadowPart('bar-active');
- expect(range).toHaveShadowPart('tick');
- expect(range).toHaveShadowPart('tick-active');
- });
- });
});
describe('range: value state classes', () => {
@@ -351,3 +333,165 @@ describe('range: value state classes', () => {
expect(range.classList.contains('range-value-max')).toBe(false);
});
});
+
+describe('range: boolean property classes', () => {
+ it('should not have any boolean classes by default', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ expect(range.classList.contains('range-disabled')).toBe(false);
+ expect(range.classList.contains('range-dual-knobs')).toBe(false);
+ expect(range.classList.contains('range-has-pin')).toBe(false);
+ });
+
+ it('should have range-disabled class when disabled is true', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ expect(range.classList.contains('range-disabled')).toBe(true);
+ });
+
+ it('should have range-dual-knobs class when dual-knobs is true', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ expect(range.classList.contains('range-dual-knobs')).toBe(true);
+ });
+
+ it('should have range-has-pin class when pin is true', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ expect(range.classList.contains('range-has-pin')).toBe(true);
+ });
+});
+
+describe('range: shadow parts', () => {
+ it('should have default shadow parts', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+
+ expect(range).toHaveShadowPart('bar');
+ expect(range).toHaveShadowPart('bar-active');
+
+ expect(range).toHaveShadowPart('label');
+
+ // knob and knob-handle parts always exist
+ expect(range).toHaveShadowPart('knob');
+ expect(range).toHaveShadowPart('knob-handle');
+
+ // knob a and knob b only exist when dualKnobs is true
+ expect(range).not.toHaveShadowPart('knob-a');
+ expect(range).not.toHaveShadowPart('knob-b');
+ expect(range).not.toHaveShadowPart('knob-handle-a');
+ expect(range).not.toHaveShadowPart('knob-handle-b');
+
+ // ticks only exist when ticks is true
+ expect(range).not.toHaveShadowPart('tick');
+ expect(range).not.toHaveShadowPart('tick-active');
+
+ // pin only exists when pin is true
+ expect(range).not.toHaveShadowPart('pin');
+ });
+
+ it('should have tick shadow parts', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+
+ expect(range).toHaveShadowPart('bar');
+ expect(range).toHaveShadowPart('bar-active');
+
+ expect(range).toHaveShadowPart('label');
+
+ // knob and knob-handle parts always exist
+ expect(range).toHaveShadowPart('knob');
+ expect(range).toHaveShadowPart('knob-handle');
+
+ // knob a and knob b only exist when dualKnobs is true
+ expect(range).not.toHaveShadowPart('knob-a');
+ expect(range).not.toHaveShadowPart('knob-b');
+ expect(range).not.toHaveShadowPart('knob-handle-a');
+ expect(range).not.toHaveShadowPart('knob-handle-b');
+
+ // ticks only exist when snaps and ticks are true
+ expect(range).toHaveShadowPart('tick');
+ expect(range).toHaveShadowPart('tick-active');
+
+ // pin only exists when pin is true
+ expect(range).not.toHaveShadowPart('pin');
+ });
+
+ it('should have pin shadow part', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+
+ expect(range).toHaveShadowPart('bar');
+ expect(range).toHaveShadowPart('bar-active');
+
+ expect(range).toHaveShadowPart('label');
+
+ // knob and knob-handle parts always exist
+ expect(range).toHaveShadowPart('knob');
+ expect(range).toHaveShadowPart('knob-handle');
+
+ // knob a and knob b only exist when dualKnobs is true
+ expect(range).not.toHaveShadowPart('knob-a');
+ expect(range).not.toHaveShadowPart('knob-b');
+ expect(range).not.toHaveShadowPart('knob-handle-a');
+ expect(range).not.toHaveShadowPart('knob-handle-b');
+
+ // ticks only exist when snaps and ticks are true
+ expect(range).not.toHaveShadowPart('tick');
+ expect(range).not.toHaveShadowPart('tick-active');
+
+ // pin only exists when pin is true
+ expect(range).toHaveShadowPart('pin');
+ });
+
+ it('should have dual knob shadow parts', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+
+ expect(range).toHaveShadowPart('bar');
+ expect(range).toHaveShadowPart('bar-active');
+
+ expect(range).toHaveShadowPart('label');
+
+ // knob and knob-handle parts always exist
+ expect(range).toHaveShadowPart('knob');
+ expect(range).toHaveShadowPart('knob-handle');
+
+ // knob a and knob b only exist when dualKnobs is true
+ expect(range).toHaveShadowPart('knob-a');
+ expect(range).toHaveShadowPart('knob-b');
+ expect(range).toHaveShadowPart('knob-handle-a');
+ expect(range).toHaveShadowPart('knob-handle-b');
+
+ // ticks only exist when snaps and ticks are true
+ expect(range).not.toHaveShadowPart('tick');
+ expect(range).not.toHaveShadowPart('tick-active');
+
+ // pin only exists when pin is true
+ expect(range).not.toHaveShadowPart('pin');
+ });
+});
From f57efc4548fb2a1d595f4ac3f67df16cc7d0a9bd Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Tue, 27 Jan 2026 17:22:09 -0500
Subject: [PATCH 04/11] feat(range): add pin a and b parts
---
core/api.txt | 2 ++
core/src/components/range/range.tsx | 4 +++-
core/src/components/range/test/range.spec.ts | 20 ++++++++++++++++++--
3 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/core/api.txt b/core/api.txt
index 2ddaf648e63..37b4ef7e02b 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -1482,6 +1482,8 @@ 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,tick
ion-range,part,tick-active
diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx
index c9bf8d983b2..3c430064821 100644
--- a/core/src/components/range/range.tsx
+++ b/core/src/components/range/range.tsx
@@ -32,6 +32,8 @@ import type {
* @part tick - An inactive tick mark.
* @part tick-active - An active tick mark.
* @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-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`.
@@ -1049,7 +1051,7 @@ const renderKnob = (
aria-valuenow={value}
>
{pin && (
-
+
{pinFormatter(value)}
)}
diff --git a/core/src/components/range/test/range.spec.ts b/core/src/components/range/test/range.spec.ts
index 5d284935cb4..1e3c7e7b504 100644
--- a/core/src/components/range/test/range.spec.ts
+++ b/core/src/components/range/test/range.spec.ts
@@ -403,6 +403,10 @@ describe('range: shadow parts', () => {
// pin only exists when pin is true
expect(range).not.toHaveShadowPart('pin');
+
+ // pin a and pin b only exist when pin is true and dualKnobs is true
+ expect(range).not.toHaveShadowPart('pin-a');
+ expect(range).not.toHaveShadowPart('pin-b');
});
it('should have tick shadow parts', async () => {
@@ -433,6 +437,10 @@ describe('range: shadow parts', () => {
// pin only exists when pin is true
expect(range).not.toHaveShadowPart('pin');
+
+ // pin a and pin b only exist when pin is true and dualKnobs is true
+ expect(range).not.toHaveShadowPart('pin-a');
+ expect(range).not.toHaveShadowPart('pin-b');
});
it('should have pin shadow part', async () => {
@@ -463,12 +471,16 @@ describe('range: shadow parts', () => {
// pin only exists when pin is true
expect(range).toHaveShadowPart('pin');
+
+ // pin a and pin b only exist when pin is true and dualKnobs is true
+ expect(range).not.toHaveShadowPart('pin-a');
+ expect(range).not.toHaveShadowPart('pin-b');
});
it('should have dual knob shadow parts', async () => {
const page = await newSpecPage({
components: [Range],
- html: `
`,
+ html: `
`,
});
const range = page.body.querySelector('ion-range')!;
@@ -492,6 +504,10 @@ describe('range: shadow parts', () => {
expect(range).not.toHaveShadowPart('tick-active');
// pin only exists when pin is true
- expect(range).not.toHaveShadowPart('pin');
+ expect(range).toHaveShadowPart('pin');
+
+ // pin a and pin b only exist when pin is true and dualKnobs is true
+ expect(range).toHaveShadowPart('pin-a');
+ expect(range).toHaveShadowPart('pin-b');
});
});
From 83803c6a850e208c21801304f4a725b3a18e90e6 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Wed, 28 Jan 2026 17:35:57 -0500
Subject: [PATCH 05/11] feat(range): add pressed and focused parts for
knob-handle, knob and pin
---
core/api.txt | 2 +
core/src/components/range/range.tsx | 57 ++++++++++++++++++++++++-----
2 files changed, 50 insertions(+), 9 deletions(-)
diff --git a/core/api.txt b/core/api.txt
index 37b4ef7e02b..4d1c6903ba1 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -1474,6 +1474,7 @@ 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
@@ -1484,6 +1485,7 @@ 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
diff --git a/core/src/components/range/range.tsx b/core/src/components/range/range.tsx
index 3c430064821..14657f97093 100644
--- a/core/src/components/range/range.tsx
+++ b/core/src/components/range/range.tsx
@@ -29,20 +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 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 bar - The inactive part of the bar.
+ * @part bar-active - The active part of the bar.
* @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 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 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',
@@ -70,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.
@@ -635,6 +638,7 @@ export class Range implements ComponentInterface {
private onBlur = () => {
if (this.hasFocus) {
this.hasFocus = false;
+ this.focusedKnob = undefined;
this.ionBlur.emit();
}
};
@@ -647,6 +651,7 @@ export class Range implements ComponentInterface {
};
private onKnobFocus = (knob: KnobName) => {
+ this.focusedKnob = knob;
if (!this.hasFocus) {
this.hasFocus = true;
this.ionFocus.emit();
@@ -677,6 +682,7 @@ export class Range implements ComponentInterface {
if (!isStillFocusedOnKnob) {
if (this.hasFocus) {
this.hasFocus = false;
+ this.focusedKnob = undefined;
this.ionBlur.emit();
}
@@ -715,6 +721,7 @@ export class Range implements ComponentInterface {
max,
step,
handleKeyboard,
+ focusedKnob,
pressedKnob,
disabled,
pin,
@@ -857,6 +864,7 @@ export class Range implements ComponentInterface {
knob: 'A',
dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'A',
+ focused: focusedKnob === 'A',
value: this.valA,
ratio: this.ratioA,
pin,
@@ -875,6 +883,7 @@ export class Range implements ComponentInterface {
knob: 'B',
dualKnobs: this.dualKnobs,
pressed: pressedKnob === 'B',
+ focused: focusedKnob === 'B',
value: this.valB,
ratio: this.ratioB,
pin,
@@ -973,6 +982,7 @@ interface RangeKnob {
max: number;
disabled: boolean;
pressed: boolean;
+ focused: boolean;
pin: boolean;
pinFormatter: PinFormatter;
inheritedAttributes: Attributes;
@@ -992,6 +1002,7 @@ const renderKnob = (
max,
disabled,
pressed,
+ focused,
pin,
handleKeyboard,
pinFormatter,
@@ -1039,7 +1050,15 @@ const renderKnob = (
'ion-activatable': true,
'ion-focusable': true,
}}
- part={dualKnobs ? (knob === 'A' ? 'knob-handle knob-handle-a' : 'knob-handle knob-handle-b') : 'knob-handle'}
+ 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}
@@ -1051,7 +1070,19 @@ const renderKnob = (
aria-valuenow={value}
>
{pin && (
-
+
{pinFormatter(value)}
)}
@@ -1062,7 +1093,15 @@ const renderKnob = (
'range-knob-b': knob === 'B',
}}
role="presentation"
- part={dualKnobs ? (knob === 'A' ? 'knob knob-a' : 'knob knob-b') : 'knob'}
+ part={[
+ 'knob',
+ dualKnobs && knob === 'A' && 'knob-a',
+ dualKnobs && knob === 'B' && 'knob-b',
+ pressed && 'pressed',
+ focused && 'focused',
+ ]
+ .filter(Boolean)
+ .join(' ')}
/>
);
From d5b257004eb8080a6b1dda9cbf710c2dadc3ccda Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Thu, 29 Jan 2026 14:46:12 -0500
Subject: [PATCH 06/11] test(range): add pressed and focused parts to spec test
---
core/src/components/range/test/range.spec.ts | 183 +++++++++++++++++++
1 file changed, 183 insertions(+)
diff --git a/core/src/components/range/test/range.spec.ts b/core/src/components/range/test/range.spec.ts
index 1e3c7e7b504..e04cbeb5f50 100644
--- a/core/src/components/range/test/range.spec.ts
+++ b/core/src/components/range/test/range.spec.ts
@@ -3,6 +3,9 @@ import { newSpecPage } from '@stencil/core/testing';
import { Item } from '../../item/item';
import { Range } from '../range';
+const waitForEvent = (el: HTMLElement, eventName: string) =>
+ new Promise
((resolve) => el.addEventListener(eventName, () => resolve(), { once: true }));
+
let sharedRange: Range;
describe('range: values', () => {
@@ -510,4 +513,184 @@ describe('range: shadow parts', () => {
expect(range).toHaveShadowPart('pin-a');
expect(range).toHaveShadowPart('pin-b');
});
+
+ it('should have pressed shadow part when pressed', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ const shadowRoot = range.shadowRoot!;
+
+ // pressed part should not exist on any element by default
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="pressed"]')).toBeNull();
+
+ // Simulate a pressed knob by setting state on component instance
+ const component = page.rootInstance as any;
+ component.pressedKnob = 'A';
+ await page.waitForChanges();
+
+ // pressed part should exist on knob-handle, knob, and pin when pressed
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="pressed"]')).not.toBeNull();
+
+ // Clear the pressed state
+ component.pressedKnob = undefined;
+ await page.waitForChanges();
+
+ // pressed part should not exist after clearing pressed state
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="pressed"]')).toBeNull();
+ });
+
+ it('should have focused shadow part when focused', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ const shadowRoot = range.shadowRoot!;
+
+ // focused part should not exist on any element by default
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="focused"]')).toBeNull();
+
+ // Focus the knob handle
+ const knobHandle = shadowRoot.querySelector('.range-knob-handle') as HTMLElement;
+ knobHandle.focus();
+ await page.waitForChanges();
+
+ // focused part should exist on knob-handle, knob, and pin when focused
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="focused"]')).not.toBeNull();
+
+ // Blur the knob handle and wait for the component to finish handling blur
+ knobHandle.blur();
+ await waitForEvent(range, 'ionBlur');
+ await page.waitForChanges();
+
+ // focused part should not exist after blur
+ expect(shadowRoot.querySelector('[part~="knob-handle"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin"][part~="focused"]')).toBeNull();
+ });
+
+ it('should have pressed shadow part on only one knob when dual-knobs is', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ const shadowRoot = range.shadowRoot!;
+ const component = page.rootInstance as any;
+
+ // pressed part should not exist on any element by default
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="pressed"]')).toBeNull();
+
+ // Press knob A
+ component.pressedKnob = 'A';
+ await page.waitForChanges();
+
+ // pressed part should exist on knob A's handle, knob, and pin
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="pressed"]')).not.toBeNull();
+ // knob B should not have pressed
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="pressed"]')).toBeNull();
+
+ // Press knob B
+ component.pressedKnob = 'B';
+ await page.waitForChanges();
+
+ // pressed part should now exist on knob B's handle, knob, and pin
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="pressed"]')).not.toBeNull();
+ // knob A should no longer have pressed
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="pressed"]')).toBeNull();
+
+ // Clear the pressed state
+ component.pressedKnob = undefined;
+ await page.waitForChanges();
+
+ // pressed part should not exist after clearing pressed state
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="pressed"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="pressed"]')).toBeNull();
+ });
+
+ it('should have focused shadow part on only one knob when dual-knobs is', async () => {
+ const page = await newSpecPage({
+ components: [Range],
+ html: ``,
+ });
+ const range = page.body.querySelector('ion-range')!;
+ const shadowRoot = range.shadowRoot!;
+
+ // focused part should not exist on any element by default
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="focused"]')).toBeNull();
+
+ // Focus knob A
+ const knobHandleA = shadowRoot.querySelector('.range-knob-handle-a') as HTMLElement;
+ knobHandleA.focus();
+ await page.waitForChanges();
+
+ // focused part should exist on knob A's handle, knob, and pin
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="focused"]')).not.toBeNull();
+ // knob B should not have focused
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="focused"]')).toBeNull();
+
+ // Focus knob B
+ const knobHandleB = shadowRoot.querySelector('.range-knob-handle-b') as HTMLElement;
+ knobHandleB.focus();
+ await page.waitForChanges();
+
+ // focused part should now exist on knob B's handle, knob, and pin
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).not.toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="focused"]')).not.toBeNull();
+ // knob A should no longer have focused
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="focused"]')).toBeNull();
+
+ knobHandleB.blur();
+ await waitForEvent(range, 'ionBlur');
+ await page.waitForChanges();
+
+ // focused part should not exist after blur
+ expect(shadowRoot.querySelector('[part~="knob-handle-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-a"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-handle-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="knob-b"][part~="focused"]')).toBeNull();
+ expect(shadowRoot.querySelector('[part~="pin-b"][part~="focused"]')).toBeNull();
+ });
});
From 6741093fe8d21b8224a8927dda4926d1fe502230 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Thu, 29 Jan 2026 14:47:16 -0500
Subject: [PATCH 07/11] test(range): update custom test to check styles instead
of screenshots
---
.../components/range/test/custom/index.html | 193 +++++++++++++++
.../components/range/test/custom/range.e2e.ts | 226 +++++++++++++++++-
...nge-custom-ios-ltr-Mobile-Chrome-linux.png | Bin 1953 -> 0 bytes
...ge-custom-ios-ltr-Mobile-Firefox-linux.png | Bin 2738 -> 0 bytes
...nge-custom-ios-ltr-Mobile-Safari-linux.png | Bin 1948 -> 0 bytes
...ange-custom-md-ltr-Mobile-Chrome-linux.png | Bin 751 -> 0 bytes
...nge-custom-md-ltr-Mobile-Firefox-linux.png | Bin 1092 -> 0 bytes
...ange-custom-md-ltr-Mobile-Safari-linux.png | Bin 782 -> 0 bytes
8 files changed, 414 insertions(+), 5 deletions(-)
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-ios-ltr-Mobile-Chrome-linux.png
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-ios-ltr-Mobile-Firefox-linux.png
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-ios-ltr-Mobile-Safari-linux.png
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-md-ltr-Mobile-Chrome-linux.png
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-md-ltr-Mobile-Firefox-linux.png
delete mode 100644 core/src/components/range/test/custom/range.e2e.ts-snapshots/range-custom-md-ltr-Mobile-Safari-linux.png
diff --git a/core/src/components/range/test/custom/index.html b/core/src/components/range/test/custom/index.html
index 7af1453aaa9..bd8be71bef0 100644
--- a/core/src/components/range/test/custom/index.html
+++ b/core/src/components/range/test/custom/index.html
@@ -14,6 +14,7 @@
@@ -61,6 +204,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -80,6 +256,23 @@
lower: '-100',
upper: '100',
};
+
+ const dualKnobs = document.getElementById('dual-knobs-range');
+ dualKnobs.value = {
+ lower: '25',
+ upper: '75',
+ };
+
+ function updateDisplayedKnobValues() {
+ document.querySelector('#dual-knobs-range [slot="start"]').textContent = dualKnobs.value.lower;
+ document.querySelector('#dual-knobs-range [slot="end"]').textContent = dualKnobs.value.upper;
+ }
+
+ updateDisplayedKnobValues();
+
+ dualKnobs.addEventListener('ionChange', () => {
+ updateDisplayedKnobValues();
+ });