From 0f2828dadb6b641003e6eabaefed029dd7065fd0 Mon Sep 17 00:00:00 2001 From: akasakariko <148060537+akasakariko@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:54:38 +0900 Subject: [PATCH] fix(modal): name the dialog from aria-labelledby aria-labelledby set on ion-modal stayed on the host, so the dialog in the shadow root had no accessible name. An ID reference only resolves against the tree of the element that carries it, so the attribute cannot name the dialog with content authored in the light DOM The referenced elements are now applied to the dialog through element reflection, which is not scoped to a tree. This names the dialog with an ion-title in the modal content, as documented fixes #31622 --- core/src/components/modal/modal.tsx | 58 ++++++++++++++++++- .../src/components/modal/test/a11y/index.html | 12 +++- .../components/modal/test/a11y/modal.e2e.ts | 15 ++++- 3 files changed, 81 insertions(+), 4 deletions(-) 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();