Skip to content
Open
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
13 changes: 13 additions & 0 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1644,10 +1644,23 @@ 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} makes this element (rather than the role-less
host) the thing that `present()` in overlays.ts focuses when
the modal opens, so assistive technologies get a focus target
that actually carries the dialog's role/label. It is not part
of the Tab order since -1 is never Tab-reachable.

This is only applied to default modals. For sheet and iOS card
modals the wrapper doubles as the drag-gesture surface, and
leaving focus on that surface interferes with the pointer
gesture (most visibly on Firefox). Those modals keep focusing
the host as before.
*/
role="dialog"
{...inheritedAttributes}
aria-modal="true"
tabIndex={!isCardModal && !isSheetModal ? -1 : undefined}
class="modal-wrapper ion-overlay-wrapper"
part="content"
ref={(el) => (this.wrapperEl = el)}
Expand Down
17 changes: 17 additions & 0 deletions core/src/components/modal/test/a11y/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,22 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});

// role="dialog" lives on .modal-wrapper, not the host, so focus must land
// there on present for screen readers (e.g. TalkBack) to enter the dialog
// (IONIC-91 / FW-7611).
test('should move focus to the dialog wrapper, not the role-less host, on present', 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');

await button.click();
await ionModalDidPresent.next();

await expect(wrapper).toHaveAttribute('role', 'dialog');
await expect(wrapper).toBeFocused();
});
});
});
27 changes: 26 additions & 1 deletion core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,32 @@ export const present = async <OverlayPresentOptions>(
* to the overlay container.
*/
if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) {
overlay.el.focus();
/**
* Some overlays (modal, alert) declare `role="dialog"`/`alertdialog`,
* `aria-modal`, and the accessible label on the `.ion-overlay-wrapper`
* element inside the overlay rather than on the host itself. Focusing
* `overlay.el` (the host) in that case hands assistive technologies a
* focus target with no accessible role or name -- screen readers that
* rely on the focus/accessibility-focus event to know a dialog opened
* (e.g. Android TalkBack, which does not treat `aria-modal` alone as a
* navigation boundary) get no usable landing point, so their linear
* navigation cursor never actually enters the overlay's content.
*
* Other overlays (action-sheet, loading, popover) keep those
* attributes on the host and leave `.ion-overlay-wrapper` without a
* `tabindex`, in which case it is not focusable and the host remains
* the correct target. Only redirect to the wrapper when it actually
* declares a `tabindex` (i.e. the component authored it to be
* focusable) so overlays that already focus the host correctly are
* left untouched.
*
* `preventScroll` keeps this purely an accessibility-focus move: we
* do not want focusing the wrapper to scroll it into view, since the
* host was already the scroll-neutral, full-viewport container.
*/
const overlayWrapper = getElementRoot(overlay.el).querySelector<HTMLElement>('.ion-overlay-wrapper');
const focusTarget = overlayWrapper?.hasAttribute('tabindex') ? overlayWrapper : overlay.el;
focusTarget.focus({ preventScroll: true });
}

/**
Expand Down
Loading