diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 94c85a43811..56f14adeaad 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -98,6 +98,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise; private resetSheetContentScroll?: () => void; private inheritedAttributes: Attributes = {}; + private ariaLabelledBy?: string; private statusBarStyle?: StatusBarStyle; private inline = false; @@ -497,7 +498,7 @@ export class Modal implements ComponentInterface, OverlayInterface { const { breakpoints, initialBreakpoint, el, htmlAttributes } = this; const isSheetModal = (this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined); - const attributesToInherit = ['aria-label', 'role']; + const attributesToInherit = ['aria-label', 'aria-labelledby', 'role']; this.inheritedAttributes = inheritAttributes(el, attributesToInherit); // Cache original parent before modal gets moved to body during presentation @@ -541,6 +542,18 @@ export class Modal implements ComponentInterface, OverlayInterface { }); } + /** + * An ID reference only resolves against the tree of the element that + * carries it. The wrapper is in the shadow root while the element the + * modal is labelled with is authored in the light DOM, so copying + * `aria-labelledby` onto the wrapper as an attribute would leave the + * dialog unnamed. The referenced elements are applied through element + * reflection in `syncAriaLabelledBy` instead. + */ + const { 'aria-labelledby': ariaLabelledBy, ...inheritedAttributes } = this.inheritedAttributes; + this.ariaLabelledBy = ariaLabelledBy; + this.inheritedAttributes = inheritedAttributes; + if (isSheetModal) { this.currentBreakpoint = this.initialBreakpoint; } @@ -574,6 +587,45 @@ export class Modal implements ComponentInterface, OverlayInterface { * is configured. */ this.triggerChanged(); + this.syncAriaLabelledBy(); + } + + /** + * Names the dialog with the elements referenced by `aria-labelledby`. + * + * An ID reference only resolves against the tree of the element that + * carries it, so the attribute cannot name the dialog with content the + * developer authored in the light DOM from inside the shadow root. Element + * reflection is not scoped to a tree, which lets the dialog be named by + * that content. + * + * This runs again whenever the content is mounted or changes, since a modal + * presented through a controller has no content to resolve when it loads. + */ + private syncAriaLabelledBy() { + const { ariaLabelledBy, el, wrapperEl } = this; + + if (wrapperEl === undefined || !('ariaLabelledByElements' in wrapperEl)) { + return; + } + + const ids = ariaLabelledBy !== undefined ? ariaLabelledBy.split(/\s+/).filter((id) => id.length > 0) : []; + const elementsById = new Map(); + + if (ids.length > 0) { + const root = el.getRootNode() as Document | ShadowRoot; + root.querySelectorAll('[id]').forEach((childEl) => elementsById.set(childEl.id, childEl)); + } + + const labelledByElements = ids + .map((id) => elementsById.get(id)) + .filter((labelledByEl): labelledByEl is Element => labelledByEl !== undefined); + + /** + * An empty reflection overrides an `aria-label` on the wrapper, so it is + * only applied when the reference resolves to at least one element. + */ + wrapperEl.ariaLabelledByElements = labelledByElements.length > 0 ? labelledByElements : null; } /** @@ -677,6 +729,8 @@ export class Modal implements ComponentInterface, OverlayInterface { await waitForMount(); } + this.syncAriaLabelledBy(); + writeTask(() => this.el.classList.add('show-modal')); // Recalculate isSheetModal before safe-area setup because framework @@ -1380,6 +1434,8 @@ export class Modal implements ComponentInterface, OverlayInterface { * the end of the body and is no longer an actual child of the modal. */ private onSlotChange = ({ target }: Event) => { + this.syncAriaLabelledBy(); + const slot = target as HTMLSlotElement; slot.assignedElements().forEach((el) => { el.querySelectorAll('ion-modal').forEach((childModal) => { diff --git a/core/src/components/modal/test/a11y/index.html b/core/src/components/modal/test/a11y/index.html index 3697958263f..732c99aee7c 100644 --- a/core/src/components/modal/test/a11y/index.html +++ b/core/src/components/modal/test/a11y/index.html @@ -15,7 +15,17 @@

Modal - a11y

- My content + My content + + + + + + My modal title + + + My content + diff --git a/core/src/components/modal/test/a11y/modal.e2e.ts b/core/src/components/modal/test/a11y/modal.e2e.ts index 0cacc470903..e4953e385c9 100644 --- a/core/src/components/modal/test/a11y/modal.e2e.ts +++ b/core/src/components/modal/test/a11y/modal.e2e.ts @@ -9,7 +9,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const button = page.locator('#open-modal'); - const modal = page.locator('ion-modal .modal-wrapper'); + const modal = page.locator('#a11y-modal .modal-wrapper'); await expect(modal).toHaveAttribute('role', 'dialog'); @@ -20,13 +20,24 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => expect(results.violations).toEqual([]); }); + test('should name the dialog with aria-labelledby', async ({ page }) => { + await page.goto(`/src/components/modal/test/a11y`, config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + await page.locator('#open-labelledby-modal').click(); + await ionModalDidPresent.next(); + + 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'); + const wrapper = page.locator('#a11y-modal .modal-wrapper'); // Open with the keyboard so :focus-visible applies to the wrapper. await button.focus();