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()}
>
,
];
+ 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 (