Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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>
201 changes: 201 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,201 @@
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', 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;

// Simulate Tab to first knob
knobA.dispatchEvent(new Event('focus'));
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 Tab to second knob (blur first, focus second)
knobA.dispatchEvent(new Event('blur'));
knobB.dispatchEvent(new Event('focus'));
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);

// Verify Arrow key navigation still works on focused knob

// Simulate Arrow Right key press on knob B
const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight' });
knobB.dispatchEvent(keyEvent);
await page.waitForChanges();

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