Skip to content

Commit 5e5efaf

Browse files
committed
fix(range): improve focus and blur handling for dual knobs
1 parent c38aa07 commit 5e5efaf

File tree

3 files changed

+271
-1
lines changed

3 files changed

+271
-1
lines changed

core/src/components/range/range.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,51 @@ export class Range implements ComponentInterface {
639639
}
640640
};
641641

642+
private onKnobFocus = (knob: KnobName) => {
643+
if (!this.hasFocus) {
644+
this.hasFocus = true;
645+
this.ionFocus.emit();
646+
}
647+
648+
// Manually manage ion-focused class for dual knobs
649+
if (this.dualKnobs && this.el.shadowRoot) {
650+
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
651+
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
652+
653+
// Remove ion-focused from both knobs first
654+
knobA?.classList.remove('ion-focused');
655+
knobB?.classList.remove('ion-focused');
656+
657+
// Add ion-focused only to the focused knob
658+
const focusedKnobEl = knob === 'A' ? knobA : knobB;
659+
focusedKnobEl?.classList.add('ion-focused');
660+
}
661+
};
662+
663+
private onKnobBlur = () => {
664+
// Check if focus is moving to another knob within the same range
665+
// by delaying the reset to allow the new focus to register
666+
setTimeout(() => {
667+
const activeElement = this.el.shadowRoot?.activeElement;
668+
const isStillFocusedOnKnob = activeElement && activeElement.classList.contains('range-knob-handle');
669+
670+
if (!isStillFocusedOnKnob) {
671+
if (this.hasFocus) {
672+
this.hasFocus = false;
673+
this.ionBlur.emit();
674+
}
675+
676+
// Remove ion-focused from both knobs when focus leaves the range
677+
if (this.dualKnobs && this.el.shadowRoot) {
678+
const knobA = this.el.shadowRoot.querySelector('.range-knob-a');
679+
const knobB = this.el.shadowRoot.querySelector('.range-knob-b');
680+
knobA?.classList.remove('ion-focused');
681+
knobB?.classList.remove('ion-focused');
682+
}
683+
}
684+
}, 0);
685+
};
686+
642687
/**
643688
* Returns true if content was passed to the "start" slot
644689
*/
@@ -813,6 +858,8 @@ export class Range implements ComponentInterface {
813858
min,
814859
max,
815860
inheritedAttributes,
861+
onKnobFocus: this.onKnobFocus,
862+
onKnobBlur: this.onKnobBlur,
816863
})}
817864

818865
{this.dualKnobs &&
@@ -828,6 +875,8 @@ export class Range implements ComponentInterface {
828875
min,
829876
max,
830877
inheritedAttributes,
878+
onKnobFocus: this.onKnobFocus,
879+
onKnobBlur: this.onKnobBlur,
831880
})}
832881
</div>
833882
);
@@ -908,11 +957,27 @@ interface RangeKnob {
908957
pinFormatter: PinFormatter;
909958
inheritedAttributes: Attributes;
910959
handleKeyboard: (name: KnobName, isIncrease: boolean) => void;
960+
onKnobFocus: (knob: KnobName) => void;
961+
onKnobBlur: () => void;
911962
}
912963

913964
const renderKnob = (
914965
rtl: boolean,
915-
{ knob, value, ratio, min, max, disabled, pressed, pin, handleKeyboard, pinFormatter, inheritedAttributes }: RangeKnob
966+
{
967+
knob,
968+
value,
969+
ratio,
970+
min,
971+
max,
972+
disabled,
973+
pressed,
974+
pin,
975+
handleKeyboard,
976+
pinFormatter,
977+
inheritedAttributes,
978+
onKnobFocus,
979+
onKnobBlur,
980+
}: RangeKnob
916981
) => {
917982
const start = rtl ? 'right' : 'left';
918983

@@ -941,6 +1006,8 @@ const renderKnob = (
9411006
ev.stopPropagation();
9421007
}
9431008
}}
1009+
onFocus={() => onKnobFocus(knob)}
1010+
onBlur={onKnobBlur}
9441011
class={{
9451012
'range-knob-handle': true,
9461013
'range-knob-a': knob === 'A',

core/src/components/range/test/basic/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ <h2>Pin</h2>
8080
lower: '10',
8181
upper: '90',
8282
};
83+
84+
dualKnobs.addEventListener('ionFocus', () => {
85+
console.log('Dual Knob ionFocus', dualKnobs.value);
86+
});
8387
</script>
8488
</body>
8589
</html>
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { newSpecPage } from '@stencil/core/testing';
2+
3+
import { Range } from '../../range';
4+
5+
describe('range: dual knobs focus management', () => {
6+
it('should properly manage initial focus with dual knobs', async () => {
7+
const page = await newSpecPage({
8+
components: [Range],
9+
html: `
10+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
11+
</ion-range>
12+
`,
13+
});
14+
15+
const range = page.body.querySelector('ion-range');
16+
expect(range).not.toBeNull();
17+
18+
await page.waitForChanges();
19+
20+
// Get the knob elements
21+
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
22+
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
23+
24+
expect(knobA).not.toBeNull();
25+
expect(knobB).not.toBeNull();
26+
27+
// Initially, neither knob should have the ion-focused class
28+
expect(knobA.classList.contains('ion-focused')).toBe(false);
29+
expect(knobB.classList.contains('ion-focused')).toBe(false);
30+
});
31+
32+
it('should show focus on the correct knob when focused via keyboard navigation', async () => {
33+
const page = await newSpecPage({
34+
components: [Range],
35+
html: `
36+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
37+
</ion-range>
38+
`,
39+
});
40+
41+
const range = page.body.querySelector('ion-range');
42+
await page.waitForChanges();
43+
44+
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
45+
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
46+
47+
// Focus knob A
48+
knobA.dispatchEvent(new Event('focus'));
49+
await page.waitForChanges();
50+
51+
// Only knob A should have the ion-focused class
52+
expect(knobA.classList.contains('ion-focused')).toBe(true);
53+
expect(knobB.classList.contains('ion-focused')).toBe(false);
54+
55+
// Focus knob B
56+
knobB.dispatchEvent(new Event('focus'));
57+
await page.waitForChanges();
58+
59+
// Only knob B should have the ion-focused class
60+
expect(knobA.classList.contains('ion-focused')).toBe(false);
61+
expect(knobB.classList.contains('ion-focused')).toBe(true);
62+
});
63+
64+
it('should remove focus from all knobs when focus leaves the range', async () => {
65+
const page = await newSpecPage({
66+
components: [Range],
67+
html: `
68+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
69+
</ion-range>
70+
`,
71+
});
72+
73+
const range = page.body.querySelector('ion-range');
74+
await page.waitForChanges();
75+
76+
const knobA = range!.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
77+
const knobB = range!.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
78+
79+
// Focus knob A
80+
knobA.dispatchEvent(new Event('focus'));
81+
await page.waitForChanges();
82+
83+
expect(knobA.classList.contains('ion-focused')).toBe(true);
84+
85+
// Blur the knob (focus leaves the range)
86+
knobA.dispatchEvent(new Event('blur'));
87+
await page.waitForChanges();
88+
89+
// Wait for the timeout in onKnobBlur to complete
90+
await new Promise((resolve) => setTimeout(resolve, 10));
91+
await page.waitForChanges();
92+
93+
// Neither knob should have the ion-focused class
94+
expect(knobA.classList.contains('ion-focused')).toBe(false);
95+
expect(knobB.classList.contains('ion-focused')).toBe(false);
96+
});
97+
98+
it('should emit ionFocus when any knob receives focus', async () => {
99+
const page = await newSpecPage({
100+
components: [Range],
101+
html: `
102+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
103+
</ion-range>
104+
`,
105+
});
106+
107+
const range = page.body.querySelector('ion-range')!;
108+
await page.waitForChanges();
109+
110+
let focusEventFired = false;
111+
range.addEventListener('ionFocus', () => {
112+
focusEventFired = true;
113+
});
114+
115+
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
116+
117+
// Focus knob A
118+
knobA.dispatchEvent(new Event('focus'));
119+
await page.waitForChanges();
120+
121+
expect(focusEventFired).toBe(true);
122+
});
123+
124+
it('should emit ionBlur when focus leaves the range completely', async () => {
125+
const page = await newSpecPage({
126+
components: [Range],
127+
html: `
128+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
129+
</ion-range>
130+
`,
131+
});
132+
133+
const range = page.body.querySelector('ion-range')!;
134+
await page.waitForChanges();
135+
136+
let blurEventFired = false;
137+
range.addEventListener('ionBlur', () => {
138+
blurEventFired = true;
139+
});
140+
141+
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
142+
143+
// Focus and then blur knob A
144+
knobA.dispatchEvent(new Event('focus'));
145+
await page.waitForChanges();
146+
147+
knobA.dispatchEvent(new Event('blur'));
148+
await page.waitForChanges();
149+
150+
// Wait for the timeout in onKnobBlur to complete
151+
await new Promise((resolve) => setTimeout(resolve, 10));
152+
await page.waitForChanges();
153+
154+
expect(blurEventFired).toBe(true);
155+
});
156+
157+
it('should correctly handle Tab navigation between knobs', async () => {
158+
const page = await newSpecPage({
159+
components: [Range],
160+
html: `
161+
<ion-range dual-knobs="true" min="0" max="100" value='{"lower": 25, "upper": 75}' aria-label="Dual range">
162+
</ion-range>
163+
`,
164+
});
165+
166+
const range = page.body.querySelector('ion-range')!;
167+
await page.waitForChanges();
168+
169+
const knobA = range.shadowRoot!.querySelector('.range-knob-a') as HTMLElement;
170+
const knobB = range.shadowRoot!.querySelector('.range-knob-b') as HTMLElement;
171+
172+
// Simulate Tab to first knob
173+
knobA.dispatchEvent(new Event('focus'));
174+
await page.waitForChanges();
175+
176+
// First knob should be focused
177+
expect(knobA.classList.contains('ion-focused')).toBe(true);
178+
expect(knobB.classList.contains('ion-focused')).toBe(false);
179+
180+
// Simulate Tab to second knob (blur first, focus second)
181+
knobA.dispatchEvent(new Event('blur'));
182+
knobB.dispatchEvent(new Event('focus'));
183+
await page.waitForChanges();
184+
185+
// Second knob should be focused, first should not
186+
expect(knobA.classList.contains('ion-focused')).toBe(false);
187+
expect(knobB.classList.contains('ion-focused')).toBe(true);
188+
189+
// Verify Arrow key navigation still works on focused knob
190+
191+
// Simulate Arrow Right key press on knob B
192+
const keyEvent = new KeyboardEvent('keydown', { key: 'ArrowRight' });
193+
knobB.dispatchEvent(keyEvent);
194+
await page.waitForChanges();
195+
196+
// The knob that visually appears focused should be the one that responds to keyboard input
197+
expect(knobB.classList.contains('ion-focused')).toBe(true);
198+
});
199+
});

0 commit comments

Comments
 (0)