diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 13dadc34e81..cacf61c5b43 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -299,6 +299,13 @@ export const createSheetGesture = ( }; const onStart = (detail: GestureDetail) => { + /** + * Firefox automatically selects the header text during drag + * due to the focusable wrapper (tabindex="-1"). Remove any + * selection that may have occurred. + */ + window.getSelection()?.removeAllRanges(); + /** * If canDismiss is anything other than `true` * then users should be able to swipe down diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts index 2b66e7eac24..d04220c88e7 100644 --- a/core/src/components/modal/gestures/swipe-to-close.ts +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -116,6 +116,13 @@ export const createSwipeToCloseGesture = ( const onStart = (detail: GestureDetail) => { const { deltaY } = detail; + /** + * Firefox automatically selects the header text during drag + * due to the focusable wrapper (tabindex="-1"). Remove any + * selection that may have occurred. + */ + window.getSelection()?.removeAllRanges(); + /** * Get the initial scrollY value so * that we can correctly reset the scrollY diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 160468f5792..0df4a448cd3 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -84,6 +84,14 @@ ion-backdrop { z-index: 10; } +/** + * The wrapper receives programmatic focus for screen readers but should not + * show a visible focus ring, which is meant only for keyboard navigation. + */ +.modal-wrapper { + outline: none; +} + .modal-shadow { position: absolute; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 0ea66ce8cfe..e5c5f1301e2 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1644,10 +1644,17 @@ export class Modal implements ComponentInterface, OverlayInterface { same element. They must also be set inside the shadow DOM otherwise ion-button will not be highlighted when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134 + + tabIndex={-1} is required so present() can move focus to this + element (which carries the dialog role) instead of the role-less + host. role="dialog" alone does not make an element focusable, so + without the tabindex focus() would be a no-op and screen readers + may not properly announce the dialog and its content when it opens. */ role="dialog" {...inheritedAttributes} aria-modal="true" + tabIndex={-1} class="modal-wrapper ion-overlay-wrapper" part="content" ref={(el) => (this.wrapperEl = el)} diff --git a/core/src/components/modal/test/a11y/modal.e2e.ts b/core/src/components/modal/test/a11y/modal.e2e.ts index 5aef2cda0d8..0cacc470903 100644 --- a/core/src/components/modal/test/a11y/modal.e2e.ts +++ b/core/src/components/modal/test/a11y/modal.e2e.ts @@ -19,5 +19,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); + + // The focused wrapper must not show a focus ring when opened via keyboard. + test('should not render a focus ring on the wrapper when presented via keyboard', async ({ page }) => { + await page.goto(`/src/components/modal/test/a11y`, config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const button = page.locator('#open-modal'); + const wrapper = page.locator('ion-modal .modal-wrapper'); + + // Open with the keyboard so :focus-visible applies to the wrapper. + await button.focus(); + await page.keyboard.press('Enter'); + await ionModalDidPresent.next(); + + await expect(wrapper).toBeFocused(); + await expect(wrapper).toHaveCSS('outline-style', 'none'); + }); }); }); diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 59b341a7d52..ddcce8f4c1c 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -593,7 +593,29 @@ export const present = async ( * to the overlay container. */ if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) { - overlay.el.focus(); + /** + * Some overlays (e.g. modal) put the dialog role and accessible label + * on the `.ion-overlay-wrapper` instead of the host. Screen readers + * need focus on the element with `role="dialog"` to properly announce + * and navigate the dialog. + * + * We only target wrappers with `tabindex`, since `role="dialog"` alone + * does not make an element focusable. If no focusable dialog wrapper + * exists (e.g. picker-legacy), we fall back to the host. + */ + const overlayWrapper = getElementRoot(overlay.el).querySelector('[role="dialog"][tabindex]'); + const focusTarget = overlayWrapper ?? overlay.el; + /** + * `preventScroll` keeps this a pure focus move so the viewport does not + * jump when the wrapper is partially off-screen (e.g. a sheet modal). + * Guard the options call so an older engine that mishandles it can never + * reject present(); we fall back to a plain focus() in that case. + */ + try { + focusTarget.focus({ preventScroll: true }); + } catch { + focusTarget.focus(); + } } /** diff --git a/core/src/utils/test/overlays/overlays.e2e.ts b/core/src/utils/test/overlays/overlays.e2e.ts index ebe7d25a273..de73fd2f011 100644 --- a/core/src/utils/test/overlays/overlays.e2e.ts +++ b/core/src/utils/test/overlays/overlays.e2e.ts @@ -421,5 +421,87 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => // verify focus is in correct location await expect(input).toBeFocused(); }); + + // Focus the role="dialog" wrapper on present so screen readers can enter. + test('should focus the modal wrapper on present', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'FW-7611', + }); + await page.setContent( + ` + + Modal Content + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + const wrapper = page.locator('ion-modal .modal-wrapper'); + + await modal.evaluate((el: HTMLIonModalElement) => el.present()); + await ionModalDidPresent.next(); + + await expect(wrapper).toHaveAttribute('role', 'dialog'); + await expect(wrapper).toBeFocused(); + }); + + test('should focus the sheet modal wrapper on present', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'FW-7611', + }); + await page.setContent( + ` + + Sheet Modal Content + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + const wrapper = page.locator('ion-modal .modal-wrapper'); + + await modal.evaluate((el: HTMLIonModalElement) => el.present()); + await ionModalDidPresent.next(); + + await expect(wrapper).toHaveAttribute('role', 'dialog'); + await expect(wrapper).toBeFocused(); + }); + + test('should focus the card modal wrapper on present', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'FW-7611', + }); + await page.setContent( + ` +
+ Root Content +
+ + Card Modal Content + + `, + config + ); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + const wrapper = page.locator('ion-modal .modal-wrapper'); + + await modal.evaluate((el: HTMLIonModalElement) => { + el.presentingElement = document.querySelector('.ion-page')!; + return el.present(); + }); + await ionModalDidPresent.next(); + + await expect(wrapper).toHaveAttribute('role', 'dialog'); + await expect(wrapper).toBeFocused(); + }); }); }); diff --git a/core/src/utils/test/playwright/drag-element.ts b/core/src/utils/test/playwright/drag-element.ts index ec0a8d9ce83..febff65078e 100644 --- a/core/src/utils/test/playwright/drag-element.ts +++ b/core/src/utils/test/playwright/drag-element.ts @@ -35,6 +35,12 @@ export const dragElementBy = async ( await page.mouse.down(); + // Firefox treats focusable elements (tabindex="-1") as draggable text and + // initiates text selection on mouse.down(). In Playwright, this selection + // locks in before the gesture detector fires, blocking pointer events. + // Clearing it here allows the drag gesture to proceed normally. + await page.evaluate(() => window.getSelection()?.removeAllRanges()); + // Drag the element. await moveElement(page, startX, startY, dragByX, dragByY);