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
58 changes: 57 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise<void>;
private resetSheetContentScroll?: () => void;
private inheritedAttributes: Attributes = {};
private ariaLabelledBy?: string;
private statusBarStyle?: StatusBarStyle;

private inline = false;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string, Element>();

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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 11 additions & 1 deletion core/src/components/modal/test/a11y/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@
<h1>Modal - a11y</h1>

<button id="open-modal">Open Modal</button>
<ion-modal aria-label="My custom label" trigger="open-modal">My content</ion-modal>
<ion-modal id="a11y-modal" aria-label="My custom label" trigger="open-modal">My content</ion-modal>

<button id="open-labelledby-modal">Open Modal with aria-labelledby</button>
<ion-modal id="labelledby-modal" aria-labelledby="modal-title" trigger="open-labelledby-modal">
<ion-header>
<ion-toolbar>
<ion-title id="modal-title">My modal title</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>My content</ion-content>
</ion-modal>
</main>
</body>
</html>
15 changes: 13 additions & 2 deletions core/src/components/modal/test/a11y/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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();
Expand Down
Loading