From 0a3a8e7581b438ddebbd3ddec3d488403a44fead Mon Sep 17 00:00:00 2001 From: Lukas Zeiml Date: Thu, 28 May 2026 12:26:37 +0200 Subject: [PATCH 01/36] menu pattern implementation --- packages/core/src/components.d.ts | 15 +++ .../menu-category/menu-category.tsx | 9 +- .../src/components/menu-item/menu-item.tsx | 51 ++++++++- packages/core/src/components/menu/menu.tsx | 100 ++++++++++++++++++ 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index ecfff15c2f7..4418f91d953 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -2605,6 +2605,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 @@ -2684,6 +2685,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 @@ -3148,6 +3150,8 @@ export namespace Components { | 'success' | 'custom'; } + interface IxPlayground { + } /** * @since 3.2.0 */ @@ -5829,6 +5833,12 @@ declare global { prototype: HTMLIxPillElement; new (): HTMLIxPillElement; }; + interface HTMLIxPlaygroundElement extends Components.IxPlayground, HTMLStencilElement { + } + var HTMLIxPlaygroundElement: { + prototype: HTMLIxPlaygroundElement; + new (): HTMLIxPlaygroundElement; + }; /** * @since 3.2.0 */ @@ -6351,6 +6361,7 @@ declare global { "ix-pane": HTMLIxPaneElement; "ix-pane-layout": HTMLIxPaneLayoutElement; "ix-pill": HTMLIxPillElement; + "ix-playground": HTMLIxPlaygroundElement; "ix-progress-indicator": HTMLIxProgressIndicatorElement; "ix-push-card": HTMLIxPushCardElement; "ix-radio": HTMLIxRadioElement; @@ -9647,6 +9658,8 @@ declare namespace LocalJSX { | 'success' | 'custom'; } + interface IxPlayground { + } /** * @since 3.2.0 */ @@ -12247,6 +12260,7 @@ declare namespace LocalJSX { "ix-pane": Omit & { [K in keyof IxPane & keyof IxPaneAttributes]?: IxPane[K] } & { [K in keyof IxPane & keyof IxPaneAttributes as `attr:${K}`]?: IxPaneAttributes[K] } & { [K in keyof IxPane & keyof IxPaneAttributes as `prop:${K}`]?: IxPane[K] }; "ix-pane-layout": Omit & { [K in keyof IxPaneLayout & keyof IxPaneLayoutAttributes]?: IxPaneLayout[K] } & { [K in keyof IxPaneLayout & keyof IxPaneLayoutAttributes as `attr:${K}`]?: IxPaneLayoutAttributes[K] } & { [K in keyof IxPaneLayout & keyof IxPaneLayoutAttributes as `prop:${K}`]?: IxPaneLayout[K] }; "ix-pill": Omit & { [K in keyof IxPill & keyof IxPillAttributes]?: IxPill[K] } & { [K in keyof IxPill & keyof IxPillAttributes as `attr:${K}`]?: IxPillAttributes[K] } & { [K in keyof IxPill & keyof IxPillAttributes as `prop:${K}`]?: IxPill[K] }; + "ix-playground": IxPlayground; "ix-progress-indicator": Omit & { [K in keyof IxProgressIndicator & keyof IxProgressIndicatorAttributes]?: IxProgressIndicator[K] } & { [K in keyof IxProgressIndicator & keyof IxProgressIndicatorAttributes as `attr:${K}`]?: IxProgressIndicatorAttributes[K] } & { [K in keyof IxProgressIndicator & keyof IxProgressIndicatorAttributes as `prop:${K}`]?: IxProgressIndicator[K] }; "ix-push-card": Omit & { [K in keyof IxPushCard & keyof IxPushCardAttributes]?: IxPushCard[K] } & { [K in keyof IxPushCard & keyof IxPushCardAttributes as `attr:${K}`]?: IxPushCardAttributes[K] } & { [K in keyof IxPushCard & keyof IxPushCardAttributes as `prop:${K}`]?: IxPushCard[K] }; "ix-radio": Omit & { [K in keyof IxRadio & keyof IxRadioAttributes]?: IxRadio[K] } & { [K in keyof IxRadio & keyof IxRadioAttributes as `attr:${K}`]?: IxRadioAttributes[K] } & { [K in keyof IxRadio & keyof IxRadioAttributes as `prop:${K}`]?: IxRadio[K] }; @@ -12388,6 +12402,7 @@ declare module "@stencil/core" { "ix-pane": LocalJSX.IntrinsicElements["ix-pane"] & JSXBase.HTMLAttributes; "ix-pane-layout": LocalJSX.IntrinsicElements["ix-pane-layout"] & JSXBase.HTMLAttributes; "ix-pill": LocalJSX.IntrinsicElements["ix-pill"] & JSXBase.HTMLAttributes; + "ix-playground": LocalJSX.IntrinsicElements["ix-playground"] & JSXBase.HTMLAttributes; /** * @since 3.2.0 */ diff --git a/packages/core/src/components/menu-category/menu-category.tsx b/packages/core/src/components/menu-category/menu-category.tsx index d1cbe7fe376..9cb88f0508a 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'; @@ -78,6 +79,12 @@ 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; diff --git a/packages/core/src/components/menu-item/menu-item.tsx b/packages/core/src/components/menu-item/menu-item.tsx index 2111c2c0c2a..37eb592d800 100644 --- a/packages/core/src/components/menu-item/menu-item.tsx +++ b/packages/core/src/components/menu-item/menu-item.tsx @@ -8,7 +8,16 @@ */ import { iconDocument } from '@siemens/ix-icons/icons'; -import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core'; +import { + Component, + Element, + h, + Host, + Method, + Prop, + State, + Watch, +} from '@stencil/core'; import { AnchorTarget } from '../button/button.interface'; import { a11yBoolean, a11yHostAttributes } from '../utils/a11y'; import { makeRef } from '../utils/make-ref'; @@ -104,6 +113,14 @@ export class MenuItem implements IxMenuItemBase { @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-', @@ -122,6 +139,14 @@ export class MenuItem implements IxMenuItemBase { 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 isDirectMenuChild = + !this.isHostedInsideCategory && !!this.hostElement.closest('ix-menu'); + this.isInMenuContext = isInMenuShadowDOM || isDirectMenuChild; + this.onIconChange(); this.menuExpanded = menuController.nativeElement?.expand || false; @@ -236,8 +261,18 @@ export class MenuItem implements IxMenuItemBase { 'ix-focusable': !this.disabled, }} aria-disabled={this.disabled ? 'true' : null} - tabIndex={this.disabled ? -1 : 0} - role={this.isHostedInsideCategory ? 'menuitem' : undefined} + tabIndex={ + this.disabled + ? -1 + : this.isInMenuContext || this.isCategory + ? this.hostTabIndex + : 0 + } + role={ + this.isHostedInsideCategory || this.isCategory || this.isInMenuContext + ? 'menuitem' + : undefined + } {...extendedAttributes} > {this.href ? ( @@ -246,6 +281,11 @@ export class MenuItem implements IxMenuItemBase { href={this.disabled ? undefined : this.href} target={this.target} rel={this.rel} + tabIndex={ + this.isInMenuContext || this.isCategory + ? this.hostTabIndex + : undefined + } ref={this.buttonRef} onKeyDown={(e: KeyboardEvent) => this.handleCategoryKeyDown(e)} onClick={(e: Event) => { @@ -260,6 +300,11 @@ export class MenuItem implements IxMenuItemBase { ) : ( @@ -345,7 +346,7 @@ export class MenuItem 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.tsx b/packages/core/src/components/menu/menu.tsx index 7f6b207211a..6b1146b8315 100644 --- a/packages/core/src/components/menu/menu.tsx +++ b/packages/core/src/components/menu/menu.tsx @@ -803,6 +803,7 @@ export class Menu { tabs: true, 'show-scrollbar': this.expand, }} + role="group" tabIndex={this.isMenuItemsOverflow ? 0 : -1} onScroll={() => this.handleOverflowIndicator()} > diff --git a/packages/react/src/components/components.server.ts b/packages/react/src/components/components.server.ts index 902c3c95497..d84bd9aa592 100644 --- a/packages/react/src/components/components.server.ts +++ b/packages/react/src/components/components.server.ts @@ -1283,7 +1283,8 @@ export const IxMenuItem: StencilReactComponent, clientModule: clientComponents.IxMenuItem as StencilReactComponent, diff --git a/packages/vue/src/components/ix-menu-item.ts b/packages/vue/src/components/ix-menu-item.ts index 243cbc4cf3b..ab2d84223b5 100644 --- a/packages/vue/src/components/ix-menu-item.ts +++ b/packages/vue/src/components/ix-menu-item.ts @@ -18,5 +18,6 @@ export const IxMenuItem: StencilVueComponent = /*@__PURE__*/ def 'href', 'target', 'rel', - 'isCategory' + 'isCategory', + 'menuCategoryLabel' ]); From 60c1be17df38f6481f398eab3aafab35a8c520ba Mon Sep 17 00:00:00 2001 From: Lukas Zeiml Date: Thu, 11 Jun 2026 13:26:10 +0200 Subject: [PATCH 10/36] a11y violation fixes --- packages/core/src/components/menu/menu.tsx | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/menu/menu.tsx b/packages/core/src/components/menu/menu.tsx index 6b1146b8315..590c947086f 100644 --- a/packages/core/src/components/menu/menu.tsx +++ b/packages/core/src/components/menu/menu.tsx @@ -620,6 +620,8 @@ export class Menu { } private updateRovingTabIndex(items: HTMLElement[], activeIndex: number) { + this.updateMenuItemPositionMetadata(items); + items.forEach((item, i) => { ( item as HTMLElement & { setTabIndex?: (v: number) => void } @@ -627,6 +629,32 @@ export class Menu { }); } + // item positions and menu size has to be set manually because slotted items and utility controls are sepparated into two groups + private updateMenuItemPositionMetadata(items: HTMLElement[]) { + const allMenuItems = [ + ...Array.from( + this.hostElement.querySelectorAll('ix-menu-item') + ), + ...Array.from( + this.hostElement.shadowRoot?.querySelectorAll( + '.menu-utility-controls > ix-menu-item' + ) ?? [] + ), + ]; + + allMenuItems.forEach((item) => { + item.removeAttribute('aria-posinset'); + item.removeAttribute('aria-setsize'); + }); + + const total = items.length; + + items.forEach((item, index) => { + item.setAttribute('aria-posinset', String(index + 1)); + item.setAttribute('aria-setsize', String(total)); + }); + } + private handleMenuFocusIn(event: FocusEvent) { const items = this.getAllFocusableItems(); const path = event.composedPath(); @@ -830,7 +858,7 @@ export class Menu {
-