Skip to content

Commit 903b799

Browse files
committed
fix(overlays): override tab navigation to skip over host elements
1 parent a2a584f commit 903b799

File tree

2 files changed

+90
-6
lines changed

2 files changed

+90
-6
lines changed

core/src/components/popover/test/basic/popover.e2e.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,6 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
266266
// Tab should focus the native input inside ion-input
267267
await page.keyboard.press(tabKey);
268268

269-
// for Firefox, ion-input is focused first
270-
// need to tab again to get to native input
271-
if (browserName === 'firefox') {
272-
await page.keyboard.press(tabKey);
273-
}
274-
275269
await expect(innerNativeInput).toBeFocused();
276270

277271
// Arrow keys should work on the ion-input

core/src/utils/overlays.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,96 @@ const connectListeners = (doc: Document) => {
370370
true
371371
);
372372

373+
// Listen for keydown events to intercept Tab navigation.
374+
// This is needed for Safari and Firefox which may skip focusable
375+
// elements or allow focus to escape the overlay.
376+
// It also ensures proper focus delegation for shadow DOM elements
377+
// like ion-textarea.
378+
doc.addEventListener(
379+
'keydown',
380+
(ev: KeyboardEvent) => {
381+
if (ev.key !== 'Tab' && ev.key !== 'Alt+Tab') return;
382+
383+
const lastOverlay = getPresentedOverlay(
384+
doc,
385+
'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-picker-legacy,ion-popover'
386+
);
387+
388+
if (!lastOverlay || lastOverlay.classList.contains(FOCUS_TRAP_DISABLE_CLASS)) return;
389+
390+
const activeElement = doc.activeElement as HTMLElement | null;
391+
392+
if (activeElement === lastOverlay) {
393+
ev.preventDefault();
394+
focusFirstDescendant(lastOverlay);
395+
return;
396+
}
397+
398+
// Check if activeElement is inside the overlay (including shadow DOM)
399+
const isInsideOverlay = activeElement
400+
? lastOverlay.contains(activeElement) ||
401+
(activeElement.getRootNode() instanceof ShadowRoot &&
402+
lastOverlay.contains((activeElement.getRootNode() as ShadowRoot).host as HTMLElement)) ||
403+
(lastOverlay.shadowRoot?.contains(activeElement) ?? false)
404+
: false;
405+
406+
if (!isInsideOverlay) return;
407+
408+
// Get all focusable elements from both light and shadow DOM
409+
const allFocusable = [
410+
...lastOverlay.querySelectorAll<HTMLElement>(focusableQueryString),
411+
...(lastOverlay.shadowRoot?.querySelectorAll<HTMLElement>(focusableQueryString) || []),
412+
];
413+
414+
if (allFocusable.length === 0) {
415+
ev.preventDefault();
416+
return;
417+
}
418+
419+
// Find current element's index (accounting for shadow DOM)
420+
const currentIndex = activeElement
421+
? allFocusable.findIndex((el) => {
422+
if (el === activeElement) return true;
423+
if (el.shadowRoot?.contains(activeElement)) return true;
424+
const rootNode = activeElement.getRootNode();
425+
return rootNode instanceof ShadowRoot && rootNode.host === el;
426+
})
427+
: -1;
428+
429+
ev.preventDefault();
430+
431+
// Helper to focus an element, handling shadow DOM properly
432+
const focusElement = (element: HTMLElement) => {
433+
const shadowRoot = element.shadowRoot;
434+
if (shadowRoot) {
435+
const innerFocusable = shadowRoot.querySelector<HTMLElement>(focusableQueryString);
436+
if (innerFocusable && typeof (element as any).setFocus !== 'function') {
437+
focusVisibleElement(innerFocusable);
438+
return;
439+
}
440+
}
441+
focusVisibleElement(element);
442+
};
443+
444+
if (ev.shiftKey) {
445+
// Shift+Tab: previous element, wrap to last if at first
446+
if (currentIndex <= 0) {
447+
focusLastDescendant(lastOverlay);
448+
} else {
449+
focusElement(allFocusable[currentIndex - 1]);
450+
}
451+
} else {
452+
// Tab: next element, wrap to first if at last
453+
if (currentIndex < 0 || currentIndex >= allFocusable.length - 1) {
454+
focusFirstDescendant(lastOverlay);
455+
} else {
456+
focusElement(allFocusable[currentIndex + 1]);
457+
}
458+
}
459+
},
460+
true
461+
);
462+
373463
// handle back-button click
374464
doc.addEventListener('ionBackButton', (ev) => {
375465
const lastOverlay = getPresentedOverlay(doc);

0 commit comments

Comments
 (0)