mirror of
				https://github.com/ionic-team/ionic-framework.git
				synced 2025-11-01 01:18:27 +08:00 
			
		
		
		
	fix(header): ensure one banner role in condensed header (#30718)
Issue number: internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> As per accessibility guidelines, there should only be one banner landmark per page. A condensed header creates two banner landmarks since 2 `ion-header` components are required on the page. `ion-header` renders with a `role="banner"` by default (when not in `ion-menu`). The visual effect of the condensed header is achieved by rendering two distinct header components. Because both components exist in the code at the same time and both have `role="banner"`, they create a duplicate landmark announcement for screen readers. This leads to a violation with the accessibility guidelines. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - The role is updated to either `none` or `banner` based off the header's active state. - Added test. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> [Preview](https://ionic-framework-git-fw-6767-ionic1.vercel.app/src/components/header/test/condense/?ionic%3Amode=ios)
This commit is contained in:
		| @ -15,6 +15,7 @@ import { | ||||
|   handleToolbarIntersection, | ||||
|   setHeaderActive, | ||||
|   setToolbarBackgroundOpacity, | ||||
|   getRoleType, | ||||
| } from './header.utils'; | ||||
|  | ||||
| /** | ||||
| @ -208,9 +209,10 @@ export class Header implements ComponentInterface { | ||||
|     const { translucent, inheritedAttributes } = this; | ||||
|     const mode = getIonMode(this); | ||||
|     const collapse = this.collapse || 'none'; | ||||
|     const isCondensed = collapse === 'condense'; | ||||
|  | ||||
|     // banner role must be at top level, so remove role if inside a menu | ||||
|     const roleType = hostContext('ion-menu', this.el) ? 'none' : 'banner'; | ||||
|     const roleType = getRoleType(hostContext('ion-menu', this.el), isCondensed, mode); | ||||
|  | ||||
|     return ( | ||||
|       <Host | ||||
|  | ||||
| @ -2,6 +2,8 @@ import { readTask, writeTask } from '@stencil/core'; | ||||
| import { clamp } from '@utils/helpers'; | ||||
|  | ||||
| const TRANSITION = 'all 0.2s ease-in-out'; | ||||
| const ROLE_NONE = 'none'; | ||||
| const ROLE_BANNER = 'banner'; | ||||
|  | ||||
| interface HeaderIndex { | ||||
|   el: HTMLIonHeaderElement; | ||||
| @ -171,6 +173,7 @@ export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => { | ||||
|   const ionTitles = toolbars.map((toolbar) => toolbar.ionTitleEl); | ||||
|  | ||||
|   if (active) { | ||||
|     headerEl.setAttribute('role', ROLE_BANNER); | ||||
|     headerEl.classList.remove('header-collapse-condense-inactive'); | ||||
|  | ||||
|     ionTitles.forEach((ionTitle) => { | ||||
| @ -179,6 +182,16 @@ export const setHeaderActive = (headerIndex: HeaderIndex, active = true) => { | ||||
|       } | ||||
|     }); | ||||
|   } else { | ||||
|     /** | ||||
|      * There can only be one banner landmark per page. | ||||
|      * By default, all ion-headers have the banner role. | ||||
|      * This causes an accessibility issue when using a | ||||
|      * condensed header since there are two ion-headers | ||||
|      * on the page at once (active and inactive). | ||||
|      * To solve this, the role needs to be toggled | ||||
|      * based on which header is active. | ||||
|      */ | ||||
|     headerEl.setAttribute('role', ROLE_NONE); | ||||
|     headerEl.classList.add('header-collapse-condense-inactive'); | ||||
|  | ||||
|     /** | ||||
| @ -244,3 +257,28 @@ export const handleHeaderFade = (scrollEl: HTMLElement, baseEl: HTMLElement, con | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Get the role type for the ion-header. | ||||
|  * | ||||
|  * @param isInsideMenu If ion-header is inside ion-menu. | ||||
|  * @param isCondensed If ion-header has collapse="condense". | ||||
|  * @param mode The current mode. | ||||
|  * @returns 'none' if inside ion-menu or if condensed in md | ||||
|  * mode, otherwise 'banner'. | ||||
|  */ | ||||
| export const getRoleType = (isInsideMenu: boolean, isCondensed: boolean, mode: 'ios' | 'md') => { | ||||
|   // If the header is inside a menu, it should not have the banner role. | ||||
|   if (isInsideMenu) { | ||||
|     return ROLE_NONE; | ||||
|   } | ||||
|   /** | ||||
|    * Only apply role="none" to `md` mode condensed headers | ||||
|    * since the large header is never shown. | ||||
|    */ | ||||
|   if (isCondensed && mode === 'md') { | ||||
|     return ROLE_NONE; | ||||
|   } | ||||
|   // Default to banner role. | ||||
|   return ROLE_BANNER; | ||||
| }; | ||||
|  | ||||
| @ -40,5 +40,45 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, c | ||||
|  | ||||
|       await expect(smallTitle).toHaveAttribute('aria-hidden', 'true'); | ||||
|     }); | ||||
|  | ||||
|     test('should only have the banner role on the active header', async ({ page }) => { | ||||
|       await page.goto('/src/components/header/test/condense', config); | ||||
|       const largeTitleHeader = page.locator('#largeTitleHeader'); | ||||
|       const smallTitleHeader = page.locator('#smallTitleHeader'); | ||||
|       const content = page.locator('ion-content'); | ||||
|  | ||||
|       await expect(largeTitleHeader).toHaveAttribute('role', 'banner'); | ||||
|       await expect(smallTitleHeader).toHaveAttribute('role', 'none'); | ||||
|  | ||||
|       await content.evaluate(async (el: HTMLIonContentElement) => { | ||||
|         await el.scrollToBottom(); | ||||
|       }); | ||||
|       await page.locator('#largeTitleHeader.header-collapse-condense-inactive').waitFor(); | ||||
|  | ||||
|       await expect(largeTitleHeader).toHaveAttribute('role', 'none'); | ||||
|       await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { | ||||
|   test.describe(title('header: condense'), () => { | ||||
|     test('should only have the banner role on the small header', async ({ page }) => { | ||||
|       await page.goto('/src/components/header/test/condense', config); | ||||
|       const largeTitleHeader = page.locator('#largeTitleHeader'); | ||||
|       const smallTitleHeader = page.locator('#smallTitleHeader'); | ||||
|       const content = page.locator('ion-content'); | ||||
|  | ||||
|       await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); | ||||
|       await expect(largeTitleHeader).toHaveAttribute('role', 'none'); | ||||
|  | ||||
|       await content.evaluate(async (el: HTMLIonContentElement) => { | ||||
|         await el.scrollToBottom(); | ||||
|       }); | ||||
|       await page.waitForChanges(); | ||||
|  | ||||
|       await expect(smallTitleHeader).toHaveAttribute('role', 'banner'); | ||||
|       await expect(largeTitleHeader).toHaveAttribute('role', 'none'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Maria Hutt
					Maria Hutt