diff --git a/core/src/components/title/test/a11y/title.e2e.ts b/core/src/components/title/test/a11y/title.e2e.ts index 7b799aa4b39..22e3f44ea11 100644 --- a/core/src/components/title/test/a11y/title.e2e.ts +++ b/core/src/components/title/test/a11y/title.e2e.ts @@ -1,3 +1,4 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; @@ -74,3 +75,52 @@ configs({ directions: ['ltr'], modes: ['ios'] }).forEach(({ title, screenshot, c }); }); }); + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('title: level 1 heading'), () => { + test('should not have accessibility violations', async ({ page }) => { + /** + * Level 1 headings must be inside of a landmark (ion-header) + */ + await page.setContent( + ` + + + My Title + + + `, + config + ); + + const results = await new AxeBuilder({ page }).analyze(); + + expect(results.violations).toEqual([]); + }); + test('should not have accessibility violations with multiple h1 elements on a hidden page', async ({ page }) => { + await page.setContent( + ` +
+ + + My Title + + +
+
+ + + My Title + + +
+ `, + config + ); + + const results = await new AxeBuilder({ page }).analyze(); + + expect(results.violations).toEqual([]); + }); + }); +}); diff --git a/core/src/components/title/test/title.spec.ts b/core/src/components/title/test/title.spec.ts new file mode 100644 index 00000000000..92513a7d889 --- /dev/null +++ b/core/src/components/title/test/title.spec.ts @@ -0,0 +1,147 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Header } from '../../header/header'; +import { Toolbar } from '../../toolbar/toolbar'; +import { ToolbarTitle } from '../title'; + +describe('title: a11y', () => { + it('should add heading level 1 attributes when inside of a landmark', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` + + + Title + + + `, + }); + + const title = page.body.querySelector('ion-title')!; + + expect(title.getAttribute('role')).toBe('heading'); + expect(title.getAttribute('aria-level')).toBe('1'); + }); + it('should not override a custom role', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` + + + Title 1 + + + `, + }); + const title = page.body.querySelector('ion-title')!; + + expect(title.getAttribute('role')).toBe('article'); + expect(title.hasAttribute('aria-level')).toBe(false); + }); + it('should not add heading level 1 attributes when outside of a landmark', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` + Title + `, + }); + + const title = page.body.querySelector('ion-title')!; + + expect(title.hasAttribute('role')).toBe(false); + expect(title.hasAttribute('aria-level')).toBe(false); + }); + it('at most one ion-title should have level 1 attributes', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` + + + Title 1 + + + + + Title 2 + + + `, + }); + + const titles = page.body.querySelectorAll('ion-title'); + + expect(titles[0].getAttribute('role')).toBe('heading'); + expect(titles[0].getAttribute('aria-level')).toBe('1'); + + expect(titles[1].hasAttribute('role')).toBe(false); + expect(titles[1].hasAttribute('aria-level')).toBe(false); + }); + it('should not add level 1 attributes if other level 1 headings exist', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` +

Title

+ + + Title 1 + + + `, + }); + + const title = page.body.querySelector('ion-title')!; + + expect(title.hasAttribute('role')).toBe(false); + expect(title.hasAttribute('aria-level')).toBe(false); + }); + it('should not add level 1 attributes if other level 1 attributes exist', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` +
Title
+ + + Title 1 + + + `, + }); + + const title = page.body.querySelector('ion-title')!; + + expect(title.hasAttribute('role')).toBe(false); + expect(title.hasAttribute('aria-level')).toBe(false); + }); + + it('should have level 1 attributes even if there is a level 1 heading on another page', async () => { + const page = await newSpecPage({ + components: [Header, Toolbar, ToolbarTitle], + html: ` +
+ + + Title 1 + + +
+ +
+ + + Title 2 + + +
+ `, + }); + + const pages = page.body.querySelectorAll('.ion-page'); + + pages.forEach((page) => { + const title = page.querySelector('ion-title')!; + + expect(title.getAttribute('role')).toBe('heading'); + expect(title.getAttribute('aria-level')).toBe('1'); + }); + }); +}); diff --git a/core/src/components/title/title.tsx b/core/src/components/title/title.tsx index 82a108071ff..55e5595cbe6 100644 --- a/core/src/components/title/title.tsx +++ b/core/src/components/title/title.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Prop, Watch, h } from '@stencil/core'; +import { doc } from '@utils/browser'; import { createColorClasses } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; @@ -56,11 +57,37 @@ export class ToolbarTitle implements ComponentInterface { } render() { + const { el } = this; const mode = getIonMode(this); const size = this.getSize(); + /** + * If there is already a level one heading + * within the context of the page then + * do not add another one. + */ + const root = el.closest('.ion-page') ?? doc?.body; + const hasHeading = root?.querySelector('h1, [role="heading"][aria-level="1"]'); + const hasRole = el.hasAttribute('role'); + + /** + * The first `ion-title` on the page is considered + * the heading. This can be customized by setting + * role="heading" aria-level="1" on another element + * or by using h1. + * + * Level 1 headings must be contained inside of a landmark, + * so we check for ion-header which is typically the landmark. + */ + const isHeading = + hasRole === false && + hasHeading === null && + root?.querySelector('ion-title') === el && + el?.closest('ion-header[role]') !== null; return (