diff --git a/.changeset/fair-falcons-search.md b/.changeset/fair-falcons-search.md new file mode 100644 index 00000000000..a807b9104fc --- /dev/null +++ b/.changeset/fair-falcons-search.md @@ -0,0 +1,6 @@ +--- +'@siemens/ix': minor +--- + +Improved **ix-menu** accessibility by implementing the W3C menubar pattern, adding better keyboard navigation and screenreader suppport. +Added properties `i18nAriaLabelMenu` and `i18nNavigationHint` for screenreader translations. diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 80fdec17e13..7984a98c8a0 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -1558,7 +1558,7 @@ export declare interface IxLinkButton extends Components.IxLinkButton {} @ProxyCmp({ - inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], + inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nAriaLabelMenu', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nNavigationHint', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], methods: ['toggleMapExpand', 'toggleMenu', 'toggleSettings', 'toggleAbout'] }) @Component({ @@ -1566,7 +1566,7 @@ export declare interface IxLinkButton extends Components.IxLinkButton {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], + inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nAriaLabelMenu', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nNavigationHint', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], outputs: ['expandChange', 'mapExpandChange', 'openAppSwitch', 'openSettings', 'openAbout'], standalone: false }) diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index f3861dea0a4..ea990a7baa2 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -1663,7 +1663,7 @@ export declare interface IxLinkButton extends Components.IxLinkButton {} @ProxyCmp({ defineCustomElementFn: defineIxMenu, - inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], + inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nAriaLabelMenu', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nNavigationHint', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], methods: ['toggleMapExpand', 'toggleMenu', 'toggleSettings', 'toggleAbout'] }) @Component({ @@ -1671,7 +1671,7 @@ export declare interface IxLinkButton extends Components.IxLinkButton {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], + inputs: ['applicationDescription', 'applicationName', 'enableToggleTheme', 'expand', 'i18nAriaLabelMenu', 'i18nCollapse', 'i18nExpand', 'i18nLegal', 'i18nNavigationHint', 'i18nSettings', 'i18nToggleTheme', 'pinned', 'showAbout', 'showSettings', 'startExpanded'], outputs: ['expandChange', 'mapExpandChange', 'openAppSwitch', 'openSettings', 'openAbout'], }) export class IxMenu { diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 187fdb7316b..af925668bdc 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -2400,6 +2400,12 @@ export namespace Components { * @default false */ "expand": boolean; + /** + * i18n aria-label for menu. Gets read out by screen readers when first focusing the menu + * @since 5.1.0 + * @default 'Application Navigation' + */ + "i18nAriaLabelMenu": string; /** * i18n label for 'Collapse' button * @default 'Collapse' @@ -2415,6 +2421,12 @@ export namespace Components { * @default 'About & legal information' */ "i18nLegal": string; + /** + * i18n description for menu keyboard navigation hint, read by screen readers when focusing the menu + * @since 5.1.0 + * @default 'Use Up and Down arrow keys to navigate between menu items' + */ + "i18nNavigationHint": string; /** * i18n label for 'Settings' button * @default 'Settings' @@ -2605,6 +2617,7 @@ export namespace Components { * Show notification count on the category */ "notifications"?: number; + "setTabIndex": (value: number) => Promise; /** * Will be shown as tooltip text, if not provided menu text content will be used. * @since 4.0.0 @@ -2675,6 +2688,7 @@ export namespace Components { * Label of the menu item. Will also be used as tooltip text */ "label"?: string; + "menuCategoryLabel"?: string; /** * Show notification count on tab */ @@ -2684,6 +2698,7 @@ export namespace Components { * @since 4.0.0 */ "rel"?: string; + "setTabIndex": (value: number) => Promise; /** * Specifies where to open the linked document when href is provided. * @since 4.0.0 @@ -9048,6 +9063,12 @@ declare namespace LocalJSX { * @default false */ "expand"?: boolean; + /** + * i18n aria-label for menu. Gets read out by screen readers when first focusing the menu + * @since 5.1.0 + * @default 'Application Navigation' + */ + "i18nAriaLabelMenu"?: string; /** * i18n label for 'Collapse' button * @default 'Collapse' @@ -9063,6 +9084,12 @@ declare namespace LocalJSX { * @default 'About & legal information' */ "i18nLegal"?: string; + /** + * i18n description for menu keyboard navigation hint, read by screen readers when focusing the menu + * @since 5.1.0 + * @default 'Use Up and Down arrow keys to navigate between menu items' + */ + "i18nNavigationHint"?: string; /** * i18n label for 'Settings' button * @default 'Settings' @@ -9355,6 +9382,7 @@ declare namespace LocalJSX { * Label of the menu item. Will also be used as tooltip text */ "label"?: string; + "menuCategoryLabel"?: string; /** * Show notification count on tab */ @@ -12038,6 +12066,8 @@ declare namespace LocalJSX { "expand": boolean; "startExpanded": boolean; "pinned": boolean; + "i18nAriaLabelMenu": string; + "i18nNavigationHint": string; "i18nLegal": string; "i18nSettings": string; "i18nToggleTheme": string; @@ -12104,6 +12134,7 @@ declare namespace LocalJSX { "target": AnchorTarget; "rel": string; "isCategory": boolean; + "menuCategoryLabel": string; } interface IxMenuSettingsAttributes { "suppressLegacyTabs": boolean; diff --git a/packages/core/src/components/menu-category/menu-category.tsx b/packages/core/src/components/menu-category/menu-category.tsx index 4a919b82c32..568e7eefe40 100644 --- a/packages/core/src/components/menu-category/menu-category.tsx +++ b/packages/core/src/components/menu-category/menu-category.tsx @@ -15,10 +15,11 @@ import { h, Host, Listen, + Method, + Mixin, Prop, State, Watch, - Mixin, } from '@stencil/core'; import { animate } from 'animejs'; import { closestIxMenu } from '../utils/application-layout/context'; @@ -27,12 +28,18 @@ import { requestAnimationFrameNoNgZone } from '../utils/requestAnimationFrame'; import type { IxMenuItemBase } from './../menu-item/menu-item.interface'; import { hasKeyboardMode } from '../utils/internal/mixins/setup.mixin'; import { DefaultMixins } from '../utils/internal/component'; +import { + InheritAriaAttributesMixin, + InheritAriaAttributesMixinContract, +} from '../utils/internal/mixins/accessibility/inherit-aria-attributes.mixin'; import { getComposedPath } from '../utils/shadow-dom'; import { makeRef } from '../utils/make-ref'; import { dropdownController } from '../dropdown/dropdown-controller'; +import { createSequentialId } from '../utils/uuid'; const DefaultIxMenuItemHeight = 40; const DefaultAnimationTimeout = 150; +let categorySequenceId = 0; @Component({ tag: 'ix-menu-category', @@ -42,8 +49,8 @@ const DefaultAnimationTimeout = 150; }, }) export class MenuCategory - extends Mixin(...DefaultMixins) - implements IxMenuItemBase + extends Mixin(...DefaultMixins, InheritAriaAttributesMixin) + implements IxMenuItemBase, InheritAriaAttributesMixinContract { @Element() override hostElement!: HTMLIxMenuCategoryElement; @@ -78,12 +85,23 @@ export class MenuCategory @State() showDropdown = false; @State() nestedItems: HTMLIxMenuItemElement[] = []; + /** @internal */ + @Method() + async setTabIndex(value: number) { + await this.categoryParentRef.current?.setTabIndex(value); + } + private observer?: MutationObserver; private menuItemsContainer?: HTMLDivElement; private ixMenu?: HTMLIxMenuElement; private readonly dropdownRef = makeRef(); private readonly categoryParentRef = makeRef(); + private readonly categoryId = createSequentialId( + 'ix-menu-category-', + categorySequenceId++ + ); + private focusFirstItemOnDropdownOpen = false; private isNestedItemActive() { return this.getNestedItems().some((item) => item.active); @@ -101,6 +119,15 @@ export class MenuCategory return items.length * DefaultIxMenuItemHeight; } + private focusFirstItem() { + const items = this.getNestedItems(); + const firstItem = items[0]; + + if (firstItem) { + requestAnimationFrameNoNgZone(() => firstItem.focus()); + } + } + private onExpandCategory(showItems: boolean) { if (showItems) { this.animateFadeIn(); @@ -176,6 +203,68 @@ export class MenuCategory this.showMenuItemDropdown(); } + private onDropdownShowChange(dropdownShow: boolean) { + if (dropdownShow) { + return; + } + + const activeElement = document.activeElement; + const isFocused = getComposedPath(activeElement as HTMLElement).includes( + this.hostElement + ); + + if (hasKeyboardMode() && isFocused) { + // Ugly workaround to restore focus to the category after the dropdown is closed, + // because focus gets lost when the dropdown is removed from the DOM. + // This is needed to ensure keyboard users can continue navigating after closing the dropdown with the keyboard. + requestAnimationFrameNoNgZone(() => + requestAnimationFrameNoNgZone(() => this.hostElement.focus()) + ); + } + } + + private onDropdownShowChanged(dropdownShown: boolean) { + this.showDropdown = dropdownShown; + + if (!dropdownShown) { + this.focusFirstItemOnDropdownOpen = false; + + return; + } + + if (this.focusFirstItemOnDropdownOpen) { + this.focusFirstItemOnDropdownOpen = false; + + this.focusFirstItem(); + } + } + + private onDropdownFocusOut() { + requestAnimationFrameNoNgZone(() => { + const activeElement = document.activeElement as HTMLElement | null; + + if (!activeElement) { + return; + } + + const activePath = getComposedPath(activeElement); + const focusInsideCategory = activePath.includes(this.hostElement); + const focusInsideDropdown = + !!this.dropdownRef.current && + activePath.includes(this.dropdownRef.current); + + if (!focusInsideCategory && !focusInsideDropdown) { + this.showDropdown = false; + } + }); + } + + private onNestedItemSelect() { + if (!this.ixMenu?.expand) { + this.showDropdown = false; + } + } + private onCategoryClick(event: MouseEvent) { event.stopPropagation(); this.handleCategoryVisibility(); @@ -185,17 +274,26 @@ export class MenuCategory if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); const isClosingPanel = this.ixMenu?.expand && this.showItems; + const isCollapsedMenu = !this.ixMenu?.expand; this.handleCategoryVisibility(); if (!isClosingPanel) { - const items = this.getNestedItems(); - const firstItem = items[0]; - if (firstItem) { - requestAnimationFrameNoNgZone(() => - requestAnimationFrameNoNgZone(() => firstItem.focus()) - ); + if (isCollapsedMenu) { + // In collapsed mode, wait until the dropdown is fully shown before moving focus. + this.focusFirstItemOnDropdownOpen = true; + + return; } + + this.focusFirstItem(); } + + return; + } + + if (event.key === 'ArrowDown' && this.showItems) { + event.preventDefault(); + this.focusFirstItem(); } } @@ -235,7 +333,20 @@ export class MenuCategory } } + private suppressAnchorWrapperTabStops() { + Array.from( + this.hostElement.querySelectorAll(':scope > a') + ) + .filter((a) => a.querySelector('ix-menu-item')) + .forEach((a) => { + if (a.getAttribute('tabindex') !== '-1') { + a.setAttribute('tabindex', '-1'); + } + }); + } + private onNestedItemsChanged(mutations?: MutationRecord[]) { + this.suppressAnchorWrapperTabStops(); const oldNestedItemsLength = this.nestedItems.length; this.nestedItems = this.getNestedItems(); @@ -271,6 +382,8 @@ export class MenuCategory } override componentWillLoad() { + super.componentWillLoad(); + const closestMenu = closestIxMenu(this.hostElement); if (!closestMenu) { throw Error('ix-menu-category can only be used as a child of ix-menu'); @@ -324,17 +437,26 @@ export class MenuCategory } override disconnectedCallback() { + super.disconnectedCallback(); + if (this.observer) { this.observer.disconnect(); } } override render() { + const inheritedA11yWithoutRole = { + ...this.inheritAriaAttributes, + }; + + delete inheritedA11yWithoutRole.role; + return ( this.onNestedItemSelect()} onPointerEnter={() => { this.showMenuItemDropdown(); }} @@ -346,8 +468,10 @@ export class MenuCategory }} > this.onKeyDown(event)} tooltipText={this.tooltipText} isCategory + menuCategoryLabel={this.label} > {this.label} @@ -378,6 +503,7 @@ export class MenuCategory 'menu-items--collapsed': !this.showItems, }} role="menu" + aria-labelledby={this.categoryId} onKeyDown={(e) => this.onMenuItemsKeyDown(e)} > {this.showItems ? : null} @@ -388,28 +514,8 @@ export class MenuCategory aria-label={this.label} closeBehavior={'both'} show={this.showDropdown} - onShowChange={({ detail: dropdownShow }) => { - if (dropdownShow) { - return; - } - - const activeElement = document.activeElement; - const isFocused = getComposedPath( - activeElement as HTMLElement - ).includes(this.hostElement); - - if (hasKeyboardMode() && isFocused) { - // Ugly workaround to restore focus to the category after the dropdown is closed, - // because focus gets lost when the dropdown is removed from the DOM. - // This is needed to ensure keyboard users can continue navigating after closing the dropdown with the keyboard. - requestAnimationFrameNoNgZone(() => - requestAnimationFrameNoNgZone(() => this.hostElement.focus()) - ); - } - }} - onShowChanged={({ detail: dropdownShown }: CustomEvent) => { - this.showDropdown = dropdownShown; - }} + onShowChange={({ detail }) => this.onDropdownShowChange(detail)} + onShowChanged={({ detail }) => this.onDropdownShowChanged(detail)} class={'category-dropdown'} anchor={this.hostElement} placement="right-start" @@ -426,16 +532,7 @@ export class MenuCategory } } }} - onFocusout={(event) => { - const relatedTarget = event.relatedTarget as HTMLElement | null; - if ( - relatedTarget && - relatedTarget !== this.hostElement && - !this.hostElement.contains(relatedTarget) - ) { - this.showDropdown = false; - } - }} + onFocusout={() => this.onDropdownFocusOut()} > { + await mount(` + + + + + Item 1 + + + Item 2 + + + + + `); + + const anchor1 = page.locator('#cat-anchor1'); + const anchor2 = page.locator('#cat-anchor2'); + + await expect(anchor1).toHaveAttribute('tabindex', '-1'); + await expect(anchor2).toHaveAttribute('tabindex', '-1'); + } +); + +regressionTest( + 'show category if anchor-wrapped items are focused', + async ({ mount, page }) => { + await mount(` + + + + + Test + + + Test 2 + + + + + `); + + const categoryElement = page.locator('ix-menu-category'); + await expect(categoryElement).toHaveClass(/hydrated/); + + // Navigate to category + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + await expect(categoryElement).toBeFocused(); + + const dropdown = categoryElement.locator('ix-dropdown'); + await expect(dropdown).not.toBeVisible(); + + await page.keyboard.press(' '); + await expect(dropdown).toBeVisible(); + + const item1 = categoryElement.locator('ix-menu-item').nth(0); + const item2 = categoryElement.locator('ix-menu-item').nth(1); + + await expect(item1).toHaveVisibleFocus(); + await page.keyboard.press('ArrowDown'); + await expect(item2).toHaveVisibleFocus(); + + await page.keyboard.press('Escape'); + await expect(dropdown).not.toBeVisible(); + await expect(categoryElement.locator('.category-parent')).toBeFocused(); + } +); + +regressionTest( + 'should move into expanded category items when pressing ArrowDown on category button', + async ({ mount, page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + + await mount(` + + + + Active Item + Item 2 + Item 3 + + + + `); + + const categoryElement = page.locator('ix-menu-category'); + const categoryButton = categoryElement.locator('.category-parent'); + const items = categoryElement.locator(':scope > ix-menu-item'); + + // Category should be expanded initially because one item is active + const menuItems = categoryElement.locator('.menu-items'); + await expect(menuItems).toHaveClass(/menu-items--expanded/); + + await categoryButton.focus(); + + await expect(categoryButton).toBeFocused(); + + // Press ArrowDown should move focus to the first nested item + await page.keyboard.press('ArrowDown'); + await expect(items.nth(0)).toHaveVisibleFocus(); + + // Press ArrowDown again should move to second item + await page.keyboard.press('ArrowDown'); + await expect(items.nth(1)).toHaveVisibleFocus(); + + // Press ArrowUp should wrap around to last item (not exit to category) + await page.keyboard.press('ArrowUp'); + await expect(items.nth(0)).toHaveVisibleFocus(); + } +); diff --git a/packages/core/src/components/menu-item/menu-item.tsx b/packages/core/src/components/menu-item/menu-item.tsx index 2111c2c0c2a..ccbcc05051f 100644 --- a/packages/core/src/components/menu-item/menu-item.tsx +++ b/packages/core/src/components/menu-item/menu-item.tsx @@ -8,15 +8,30 @@ */ import { iconDocument } from '@siemens/ix-icons/icons'; -import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core'; +import { + Component, + Element, + h, + Host, + Method, + Mixin, + Prop, + State, + Watch, +} from '@stencil/core'; import { AnchorTarget } from '../button/button.interface'; -import { a11yBoolean, a11yHostAttributes } from '../utils/a11y'; +import { DefaultMixins } from '../utils/internal/component'; +import { + InheritAriaAttributesMixin, + InheritAriaAttributesMixinContract, +} from '../utils/internal/mixins/accessibility/inherit-aria-attributes.mixin'; import { makeRef } from '../utils/make-ref'; import { menuController } from '../utils/menu-service/menu-service'; import { createMutationObserver } from '../utils/mutation-observer'; import { Disposable } from '../utils/typed-event'; import { createSequentialId } from '../utils/uuid'; import { IxMenuItemBase } from './menu-item.interface'; +import { a11yBoolean } from '../utils/a11y'; let sequenceId = 0; @@ -30,7 +45,10 @@ let sequenceId = 0; delegatesFocus: true, }, }) -export class MenuItem implements IxMenuItemBase { +export class MenuItem + extends Mixin(...DefaultMixins, InheritAriaAttributesMixin) + implements IxMenuItemBase, InheritAriaAttributesMixinContract +{ /** * Label of the menu item. Will also be used as tooltip text */ @@ -99,11 +117,21 @@ export class MenuItem implements IxMenuItemBase { /** @internal */ @Prop() isCategory: boolean = false; - @Element() hostElement!: HTMLIxMenuItemElement; + /** @internal */ + @Prop() menuCategoryLabel?: string; + + @Element() override hostElement!: HTMLIxMenuItemElement; @State() tooltip?: string; - @State() ariaHiddenTooltip = false; @State() menuExpanded: boolean = false; + @State() private isInMenuContext = false; + @State() private hostTabIndex = -1; + + /** @internal */ + @Method() + async setTabIndex(value: number) { + this.hostTabIndex = value; + } private readonly internalItemId = createSequentialId( 'ix-menu-item-', @@ -118,10 +146,25 @@ export class MenuItem implements IxMenuItemBase { this.setTooltip(); }); - componentWillLoad() { + override componentWillLoad() { + super.componentWillLoad(); + this.isHostedInsideCategory = !!this.hostElement.closest('ix-menu-category'); + const rootNode = this.hostElement.getRootNode(); + const isInMenuShadowDOM = + rootNode instanceof ShadowRoot && + rootNode.host?.tagName?.toLowerCase() === 'ix-menu'; + const directParent = this.hostElement.parentElement; + const isMenuChild = + !this.isHostedInsideCategory && + (directParent?.tagName?.toLowerCase() === 'ix-menu' || + (directParent?.tagName?.toLowerCase() === 'a' && + directParent?.parentElement?.tagName?.toLowerCase() === 'ix-menu')); + + this.isInMenuContext = isInMenuShadowDOM || isMenuChild; + this.onIconChange(); this.menuExpanded = menuController.nativeElement?.expand || false; @@ -130,7 +173,7 @@ export class MenuItem implements IxMenuItemBase { ); } - componentWillRender() { + override componentWillRender() { this.setTooltip(); } @@ -140,13 +183,9 @@ export class MenuItem implements IxMenuItemBase { this.label ?? this.hostElement.textContent ?? undefined; - - this.ariaHiddenTooltip = - this.tooltipText === this.label || - this.tooltipText === this.hostElement.textContent; } - connectedCallback() { + override connectedCallback() { this.observer.observe(this.hostElement, { subtree: true, childList: true, @@ -154,7 +193,9 @@ export class MenuItem implements IxMenuItemBase { }); } - disconnectedCallback() { + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.observer) { this.observer.disconnect(); } @@ -177,15 +218,50 @@ export class MenuItem implements IxMenuItemBase { } } + private getAriaLabel() { + if ('aria-label' in this.inheritAriaAttributes) { + return this.inheritAriaAttributes['aria-label']; + } + + const hasDistinctTooltip = + this.tooltipText && + this.tooltipText !== this.label && + this.tooltipText !== this.hostElement.textContent; + + if (hasDistinctTooltip) { + return `${this.label ?? this.menuCategoryLabel ?? this.hostElement.textContent ?? ''} ${this.tooltipText}`; + } + + return undefined; + } + + private getEffectiveRole(externalRole?: string) { + const internalRole = + this.isHostedInsideCategory || this.isCategory || this.isInMenuContext + ? 'menuitem' + : undefined; + + return externalRole ?? internalRole; + } + private returnFocusToParentCategoryMenuItem() { - const categoryMenuItem = this.hostElement - .closest('ix-menu-category') - ?.shadowRoot?.querySelector('ix-menu-item.category-parent'); + const categoryElement = + this.hostElement.closest('ix-menu-category'); + const categoryMenuItem = categoryElement?.shadowRoot?.querySelector( + 'ix-menu-item.category-parent' + ) as HTMLElement | null; + + categoryElement?.dispatchEvent( + new CustomEvent('ixMenuCategoryItemSelect', { + bubbles: true, + composed: true, + }) + ); categoryMenuItem?.focus(); } - render() { + override render() { let extendedAttributes = {}; if (this.home) { extendedAttributes = { @@ -199,11 +275,14 @@ export class MenuItem implements IxMenuItemBase { }; } - const hostA11y = a11yHostAttributes(this.hostElement); + const { role: externalRole, ...inheritedA11yWithoutRole } = + this.inheritAriaAttributes; + + const effectiveRole = this.getEffectiveRole(externalRole); const commonAttributes = { class: 'tab', - ...hostA11y, + ...inheritedA11yWithoutRole, }; const menuContent = [ @@ -225,6 +304,8 @@ export class MenuItem implements IxMenuItemBase { , ]; + const ariaLabel = this.getAriaLabel(); + return ( {this.href ? ( this.handleCategoryKeyDown(e)} onClick={(e: Event) => { @@ -254,14 +338,26 @@ export class MenuItem implements IxMenuItemBase { e.stopPropagation(); } }} + aria-disabled={a11yBoolean(this.disabled)} + aria-label={ariaLabel} + aria-current={this.active ? 'page' : undefined} > {menuContent} ) : ( @@ -271,7 +367,7 @@ export class MenuItem implements IxMenuItemBase { placement={'right'} showDelay={1000} interactive={false} - aria-hidden={a11yBoolean(this.ariaHiddenTooltip)} + aria-hidden="true" aria-labelledby={this.internalItemId} > {this.tooltip} diff --git a/packages/core/src/components/menu/menu-expand-icon.tsx b/packages/core/src/components/menu/menu-expand-icon.tsx index 3aa460fe20c..b74959480c9 100644 --- a/packages/core/src/components/menu/menu-expand-icon.tsx +++ b/packages/core/src/components/menu/menu-expand-icon.tsx @@ -43,7 +43,7 @@ export class MenuExpandIcon { return (