Skip to content

fix(range): improve focus and blur handling for dual knobs #30482

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 13, 2025
Merged
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
69 changes: 68 additions & 1 deletion core/src/components/range/range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,51 @@ export class Range implements ComponentInterface {
}
};

private onKnobFocus = (knob: KnobName) => {
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 = () => {
// Check if focus is moving to another knob within the same range
// by delaying the reset to allow the new focus to register
setTimeout(() => {
const activeElement = this.el.shadowRoot?.activeElement;
const isStillFocusedOnKnob = activeElement && activeElement.classList.contains('range-knob-handle');

if (!isStillFocusedOnKnob) {
if (this.hasFocus) {
this.hasFocus = false;
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);
};

/**
* Returns true if content was passed to the "start" slot
*/
Expand Down Expand Up @@ -813,6 +858,8 @@ export class Range implements ComponentInterface {
min,
max,
inheritedAttributes,
onKnobFocus: this.onKnobFocus,
onKnobBlur: this.onKnobBlur,
})}

{this.dualKnobs &&
Expand All @@ -828,6 +875,8 @@ export class Range implements ComponentInterface {
min,
max,
inheritedAttributes,
onKnobFocus: this.onKnobFocus,
onKnobBlur: this.onKnobBlur,
})}
</div>
);
Expand Down Expand Up @@ -908,11 +957,27 @@ interface RangeKnob {
pinFormatter: PinFormatter;
inheritedAttributes: Attributes;
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
onKnobFocus: (knob: KnobName) => void;
onKnobBlur: () => void;
}

const renderKnob = (
rtl: boolean,
{ knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard, pinFormatter, inheritedAttributes }: RangeKnob
{
knob,
value,
ratio,
min,
max,
disabled,
pressed,
pin,
handleKeyboard,
pinFormatter,
inheritedAttributes,
onKnobFocus,
onKnobBlur,
}: RangeKnob
) => {
const start = rtl ? 'right' : 'left';

Expand Down Expand Up @@ -941,6 +1006,8 @@ const renderKnob = (
ev.stopPropagation();
}
}}
onFocus={() => onKnobFocus(knob)}
onBlur={onKnobBlur}
class={{
'range-knob-handle': true,
'range-knob-a': knob === 'A',
Expand Down
4 changes: 4 additions & 0 deletions core/src/components/range/test/basic/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ <h2>Pin</h2>
lower: '10',
upper: '90',
};

dualKnobs.addEventListener('ionFocus', () => {
console.log('Dual Knob ionFocus', dualKnobs.value);
});
</script>
</body>
</html>
244 changes: 244 additions & 0 deletions core/src/components/range/test/basic/range.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { newSpecPage } from '@stencil/core/testing';

import { Range } from '../../range';

describe('range: dual knobs focus management', () => {
it('should properly manage initial focus with dual knobs', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
`,
});

const range = page.body.querySelector('ion-range');
expect(range).not.toBeNull();

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;

expect(knobA).not.toBeNull();
expect(knobB).not.toBeNull();

// Initially, neither knob should have the ion-focused class
expect(knobA.classList.contains('ion-focused')).toBe(false);
expect(knobB.classList.contains('ion-focused')).toBe(false);
});

it('should show focus on the correct knob when focused via keyboard navigation', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
`,
});

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;

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
await page.waitForChanges();

// Only knob A should have the ion-focused class
expect(knobA.classList.contains('ion-focused')).toBe(true);
expect(knobB.classList.contains('ion-focused')).toBe(false);

// Focus knob B
knobB.dispatchEvent(new Event('focus'));
await page.waitForChanges();

// Only knob B should have the ion-focused class
expect(knobA.classList.contains('ion-focused')).toBe(false);
expect(knobB.classList.contains('ion-focused')).toBe(true);
});

it('should remove focus from all knobs when focus leaves the range', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
`,
});

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;

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
await page.waitForChanges();

expect(knobA.classList.contains('ion-focused')).toBe(true);

// Blur the knob (focus leaves the range)
knobA.dispatchEvent(new Event('blur'));
await page.waitForChanges();

// Wait for the timeout in onKnobBlur to complete
await new Promise((resolve) => setTimeout(resolve, 10));
await page.waitForChanges();

// Neither knob should have the ion-focused class
expect(knobA.classList.contains('ion-focused')).toBe(false);
expect(knobB.classList.contains('ion-focused')).toBe(false);
});

it('should emit ionFocus when any knob receives focus but only once until blur', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
`,
});

const range = page.body.querySelector('ion-range')!;
await page.waitForChanges();

let focusEventFiredCount = 0;
range.addEventListener('ionFocus', () => {
focusEventFiredCount++;
});

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

// Focus knob A
knobA.dispatchEvent(new Event('focus'));
knobB.dispatchEvent(new Event('focus'));
await page.waitForChanges();

expect(focusEventFiredCount).toBe(1);
});

it('should emit ionBlur when focus leaves the range completely', async () => {
const page = await newSpecPage({
components: [Range],
html: `
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
`,
});

const range = page.body.querySelector('ion-range')!;
await page.waitForChanges();

let blurEventFired = false;
range.addEventListener('ionBlur', () => {
blurEventFired = true;
});

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

// Focus and then blur knob A
knobA.dispatchEvent(new Event('focus'));
await page.waitForChanges();

knobA.dispatchEvent(new Event('blur'));
await page.waitForChanges();

// Wait for the timeout in onKnobBlur to complete
await new Promise((resolve) => setTimeout(resolve, 10));
await page.waitForChanges();

expect(blurEventFired).toBe(true);
});

it('should correctly handle Tab navigation between knobs using KeyboardEvent', async () => {
// Using KeyboardEvent to simulate Tab key is more realistic than just firing focus events
// because it tests the actual keyboard navigation behavior users would experience
const page = await newSpecPage({
components: [Range],
html: `
<button id="before">Before</button>
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
</ion-range>
<button id="after">After</button>
`,
});

const range = page.body.querySelector('ion-range')!;
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;

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

// Simulate Tab key press - this would move focus to first knob
let tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
bubbles: true,
cancelable: true,
});

beforeButton.dispatchEvent(tabEvent);
knobA.focus(); // Browser would focus next tabindex element
await page.waitForChanges();

// First knob should be focused
expect(knobA.classList.contains('ion-focused')).toBe(true);
expect(knobB.classList.contains('ion-focused')).toBe(false);

// Simulate another Tab key press - this would move focus to second knob
tabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
bubbles: true,
cancelable: true,
});

knobA.dispatchEvent(tabEvent);
knobB.focus(); // Browser would focus next tabindex element
await page.waitForChanges();

// Second knob should be focused, first should not
expect(knobA.classList.contains('ion-focused')).toBe(false);
expect(knobB.classList.contains('ion-focused')).toBe(true);

// Simulate Shift+Tab (reverse tab) - should go back to first knob
const shiftTabEvent = new KeyboardEvent('keydown', {
key: 'Tab',
code: 'Tab',
shiftKey: true,
bubbles: true,
cancelable: true,
});

knobB.dispatchEvent(shiftTabEvent);
knobA.focus(); // Browser would focus previous tabindex element
await page.waitForChanges();

// First knob should be focused again
expect(knobA.classList.contains('ion-focused')).toBe(true);
expect(knobB.classList.contains('ion-focused')).toBe(false);

// Verify Arrow key navigation still works on focused knob
const arrowEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
code: 'ArrowRight',
bubbles: true,
cancelable: true,
});
knobA.dispatchEvent(arrowEvent);
await page.waitForChanges();

// The knob that visually appears focused should be the one that responds to keyboard input
expect(knobA.classList.contains('ion-focused')).toBe(true);
});
});
Loading