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 (