From c7794baf394423441ac3ec98ea6366d765fc0392 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 4 Nov 2025 16:20:56 -0500 Subject: [PATCH 1/2] fix(aria/menu): rtl text direction --- src/aria/menu/menu.spec.ts | 90 +++++++++++++++++++++++++++--- src/aria/menu/menu.ts | 21 ++----- src/aria/private/menu/menu.spec.ts | 85 ++++++++++++++++++++++++++-- src/aria/private/menu/menu.ts | 9 +++ 4 files changed, 178 insertions(+), 27 deletions(-) diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index b64fdc479e15..7fd60235bcf1 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -2,6 +2,7 @@ import {Component, DebugElement} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Menu, MenuBar, MenuItem, MenuTrigger} from './menu'; +import {provideFakeDirectionality} from '@angular/cdk/testing/private'; describe('Standalone Menu Pattern', () => { let fixture: ComponentFixture; @@ -37,8 +38,10 @@ describe('Standalone Menu Pattern', () => { fixture.detectChanges(); }; - function setupMenu() { - TestBed.configureTestingModule({}); + function setupMenu(opts?: {textDirection: 'ltr' | 'rtl'}) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')], + }); fixture = TestBed.createComponent(StandaloneMenuExample); fixture.detectChanges(); getItem('Apple')?.focus(); @@ -407,6 +410,44 @@ describe('Standalone Menu Pattern', () => { expect(isSubmenuExpanded()).toBe(true); }); }); + + describe('RTL', () => { + function isSubmenuExpanded(): boolean { + const berries = getItem('Berries'); + return berries?.getAttribute('aria-expanded') === 'true'; + } + + beforeEach(() => setupMenu({textDirection: 'rtl'})); + + it('should open submenu on arrow left', () => { + const apple = getItem('Apple'); + const banana = getItem('Banana'); + const berries = getItem('Berries'); + const blueberry = getItem('Blueberry'); + + keydown(apple!, 'ArrowDown'); + keydown(banana!, 'ArrowDown'); + keydown(berries!, 'ArrowLeft'); + + expect(isSubmenuExpanded()).toBe(true); + expect(document.activeElement).toBe(blueberry); + }); + + it('should close submenu on arrow right', () => { + const apple = getItem('Apple'); + const banana = getItem('Banana'); + const berries = getItem('Berries'); + const blueberry = getItem('Blueberry'); + + keydown(apple!, 'ArrowDown'); + keydown(banana!, 'ArrowDown'); + keydown(berries!, 'ArrowLeft'); + keydown(blueberry!, 'ArrowRight'); + + expect(isSubmenuExpanded()).toBe(false); + expect(document.activeElement).toBe(berries); + }); + }); }); describe('Menu Trigger Pattern', () => { @@ -601,8 +642,10 @@ describe('Menu Bar Pattern', () => { fixture.detectChanges(); }; - function setupMenu() { - TestBed.configureTestingModule({}); + function setupMenu(opts?: {textDirection: 'ltr' | 'rtl'}) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')], + }); fixture = TestBed.createComponent(MenuBarExample); fixture.detectChanges(); getMenuBarItem('File')?.focus(); @@ -634,10 +677,7 @@ describe('Menu Bar Pattern', () => { } describe('Navigation', () => { - beforeEach(() => { - setupMenu(); - getMenuBarItem('File')?.focus(); - }); + beforeEach(() => setupMenu()); it('should navigate to the first item on arrow down', () => { const file = getMenuBarItem('File'); @@ -866,6 +906,40 @@ describe('Menu Bar Pattern', () => { expect(isExpanded('Edit')).toBe(true); }); }); + + describe('RTL', () => { + beforeEach(() => setupMenu({textDirection: 'rtl'})); + + it('should focus the first item of the next menubar item on arrow left', () => { + const edit = getMenuBarItem('Edit'); + const file = getMenuBarItem('File'); + const view = getMenuBarItem('View'); + const documentation = getMenuBarItem('Documentation'); + const zoomIn = getMenuItem('Zoom In'); + + keydown(file!, 'ArrowLeft'); + keydown(edit!, 'ArrowLeft'); + keydown(view!, 'ArrowDown'); + + keydown(zoomIn!, 'ArrowLeft'); + expect(document.activeElement).toBe(documentation); + }); + + it('should focus the first item of the previous menubar item on arrow right', () => { + const edit = getMenuBarItem('Edit'); + const file = getMenuBarItem('File'); + const view = getMenuBarItem('View'); + const undo = getMenuItem('Undo'); + const zoomIn = getMenuItem('Zoom In'); + + keydown(file!, 'ArrowLeft'); + keydown(edit!, 'ArrowLeft'); + keydown(view!, 'ArrowDown'); + + keydown(zoomIn!, 'ArrowRight'); + expect(document.activeElement).toBe(undo); + }); + }); }); @Component({ diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index 4d7a74b65314..61aeda330fe1 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -31,7 +31,6 @@ import { DeferredContentAware, } from '@angular/aria/private'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {toSignal} from '@angular/core/rxjs-interop'; import {Directionality} from '@angular/cdk/bidi'; /** @@ -59,11 +58,12 @@ export class MenuTrigger { /** A reference to the menu trigger element. */ private readonly _elementRef = inject(ElementRef); + /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ + readonly textDirection = inject(Directionality).valueSignal; + /** A reference to the menu element. */ readonly element: HTMLButtonElement = this._elementRef.nativeElement; - // TODO(wagnermaciel): See we can remove the need to pass in a submenu. - /** The menu associated with the trigger. */ menu = input | undefined>(undefined); @@ -72,6 +72,7 @@ export class MenuTrigger { /** The menu trigger ui pattern instance. */ _pattern: MenuTriggerPattern = new MenuTriggerPattern({ + textDirection: this.textDirection, element: computed(() => this._elementRef.nativeElement), menu: computed(() => this.menu()?._pattern), }); @@ -143,12 +144,7 @@ export class Menu { readonly element: HTMLElement = this._elementRef.nativeElement; /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private readonly _directionality = inject(Directionality); - - /** A signal wrapper for directionality. */ - readonly textDirection = toSignal(this._directionality.change, { - initialValue: this._directionality.value, - }); + readonly textDirection = inject(Directionality).valueSignal; /** The unique ID of the menu. */ readonly id = input(inject(_IdGenerator).getId('ng-menu-', true)); @@ -278,12 +274,7 @@ export class MenuBar { readonly element: HTMLElement = this._elementRef.nativeElement; /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private readonly _directionality = inject(Directionality); - - /** A signal wrapper for directionality. */ - readonly textDirection = toSignal(this._directionality.change, { - initialValue: this._directionality.value, - }); + readonly textDirection = inject(Directionality).valueSignal; /** The value of the menu. */ readonly value = model([]); diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index 824ee1b566b3..739e3642d609 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -36,24 +36,25 @@ function clickMenuItem(items: MenuItemPattern[], index: number, mods?: Modi } as unknown as PointerEvent; } -function getMenuTriggerPattern() { +function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) { const element = signal(document.createElement('button')); const submenu = signal | undefined>(undefined); const trigger = new MenuTriggerPattern({ + textDirection: signal(opts?.textDirection || 'ltr'), element, menu: submenu, }); return trigger; } -function getMenuBarPattern(values: string[]) { +function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl'}) { const items = signal([]); const menubar = new MenuBarPattern({ items: items, activeItem: signal(undefined), orientation: signal('horizontal'), - textDirection: signal('ltr'), + textDirection: signal(opts?.textDirection || 'ltr'), multi: signal(false), selectionMode: signal('explicit'), value: signal([]), @@ -86,6 +87,7 @@ function getMenuBarPattern(values: string[]) { function getMenuPattern( parent: undefined | MenuItemPattern | MenuTriggerPattern, values: string[], + opts?: {textDirection: 'ltr' | 'rtl'}, ) { const items = signal([]); @@ -99,7 +101,7 @@ function getMenuPattern( softDisabled: signal(true), multi: signal(false), focusMode: signal('activedescendant'), - textDirection: signal('ltr'), + textDirection: signal(opts?.textDirection || 'ltr'), orientation: signal('vertical'), selectionMode: signal('explicit'), element: signal(document.createElement('div')), @@ -409,6 +411,27 @@ describe('Standalone Menu Pattern', () => { expect(submenu.isVisible()).toBe(true); }); }); + + describe('RTL', () => { + beforeEach(() => { + const opts = {textDirection: 'rtl' as const}; + menu = getMenuPattern(undefined, ['a', 'b', 'c'], opts); + submenu = getMenuPattern(menu.inputs.items()[0], ['d', 'e'], opts); + }); + + it('should open submenu on arrow left', () => { + menu.onKeydown(left()); + expect(submenu.isVisible()).toBe(true); + }); + + it('should close submenu on arrow right', () => { + menu.onKeydown(left()); + expect(submenu.isVisible()).toBe(true); + + submenu.onKeydown(right()); + expect(submenu.isVisible()).toBe(false); + }); + }); }); describe('Menu Trigger Pattern', () => { @@ -830,5 +853,59 @@ describe('Menu Bar Pattern', () => { expect(menubarItems[0].expanded()).toBe(true); expect(menubar.inputs.activeItem()).toBe(menubarItems[0]); }); + + describe('RTL', () => { + beforeEach(() => { + const opts = {textDirection: 'rtl' as const}; + menubar = getMenuBarPattern(['a', 'b', 'c'], opts); + menuA = getMenuPattern(menubar.inputs.items()[0], ['apple', 'avocado'], opts); + menuB = getMenuPattern(menubar.inputs.items()[1], ['banana', 'blueberry'], opts); + menuC = getMenuPattern(menubar.inputs.items()[2], ['cherry', 'cranberry'], opts); + }); + + it('should close on arrow left on a leaf menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + expect(menuA.isVisible()).toBe(true); + + menuA.onKeydown(left()); + + expect(menuA.isVisible()).toBe(false); + expect(menubarItems[0].expanded()).toBe(false); + }); + + it('should close on arrow right on a root menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 1)); + expect(menuB.isVisible()).toBe(true); + + menuB.onKeydown(right()); + + expect(menuB.isVisible()).toBe(false); + expect(menubarItems[1].expanded()).toBe(false); + }); + + it('should expand the next menu bar item on arrow left on a leaf menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 0)); + + menuA.onKeydown(left()); + + expect(menuB.isVisible()).toBe(true); + expect(menubarItems[1].expanded()).toBe(true); + expect(menubar.inputs.activeItem()).toBe(menubarItems[1]); + }); + + it('should expand the previous menu bar item on arrow right on a root menu item', () => { + const menubarItems = menubar.inputs.items(); + menubar.onClick(clickMenuItem(menubarItems, 1)); + + menuB.onKeydown(right()); + + expect(menuA.isVisible()).toBe(true); + expect(menubarItems[0].expanded()).toBe(true); + expect(menubar.inputs.activeItem()).toBe(menubarItems[0]); + }); + }); }); }); diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index db8cd421571d..c2315b16de5f 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -18,6 +18,9 @@ export interface MenuBarInputs extends Omit, V> /** Callback function triggered when a menu item is selected. */ onSelect?: (value: V) => void; + + /** The text direction of the menu bar. */ + textDirection: SignalLike<'ltr' | 'rtl'>; } /** The inputs for the MenuPattern class. */ @@ -34,6 +37,9 @@ export interface MenuInputs /** Callback function triggered when a menu item is selected. */ onSelect?: (value: V) => void; + + /** The text direction of the menu bar. */ + textDirection: SignalLike<'ltr' | 'rtl'>; } /** The inputs for the MenuTriggerPattern class. */ @@ -43,6 +49,9 @@ export interface MenuTriggerInputs { /** A reference to the menu associated with the trigger. */ menu: SignalLike | undefined>; + + /** The text direction of the menu bar. */ + textDirection: SignalLike<'ltr' | 'rtl'>; } /** The inputs for the MenuItemPattern class. */ From a76c0db6c92190cdfeabc280eb321664710cb7a7 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 4 Nov 2025 18:03:25 -0500 Subject: [PATCH 2/2] docs(aria/menu): rtl menu bar example --- src/components-examples/aria/menu/BUILD.bazel | 1 + src/components-examples/aria/menu/index.ts | 1 + .../menu-bar-rtl/menu-bar-rtl-example.html | 329 ++++++++++++++++++ .../menu/menu-bar-rtl/menu-bar-rtl-example.ts | 33 ++ .../aria/menu/menu-example.css | 1 + .../aria/menu/simple-menu.ts | 8 +- src/dev-app/aria-menu/menu-demo.html | 5 + src/dev-app/aria-menu/menu-demo.ts | 9 +- 8 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.html create mode 100644 src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.ts diff --git a/src/components-examples/aria/menu/BUILD.bazel b/src/components-examples/aria/menu/BUILD.bazel index 3b006c241567..5827e26e1c7b 100644 --- a/src/components-examples/aria/menu/BUILD.bazel +++ b/src/components-examples/aria/menu/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//src/aria/menu", + "//src/cdk/a11y", ], ) diff --git a/src/components-examples/aria/menu/index.ts b/src/components-examples/aria/menu/index.ts index fd502b060d90..eb1cc8bc0c2c 100644 --- a/src/components-examples/aria/menu/index.ts +++ b/src/components-examples/aria/menu/index.ts @@ -1,4 +1,5 @@ export {MenuBarExample} from './menu-bar/menu-bar-example'; +export {MenuBarRTLExample} from './menu-bar-rtl/menu-bar-rtl-example'; export {MenuContextExample} from './menu-context/menu-context-example'; export {MenuTriggerExample} from './menu-trigger/menu-trigger-example'; export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example'; diff --git a/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.html b/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.html new file mode 100644 index 000000000000..b80f45019a79 --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.html @@ -0,0 +1,329 @@ +
+
File
+ +
+ +
+ article + New + ⌘N +
+ +
+ folder + Open + ⌘O +
+ +
+ file_copy + Make a copy +
+ + + +
+ person_add + Share + arrow_left +
+ +
+ +
+ person_add + Share with others +
+ +
+ public + Publish to web +
+
+
+ +
+ download + Download +
+ +
+ print + Print +
+ + + +
+ edit + Rename +
+ +
+ delete + Move to trash +
+
+
+ +
Edit
+ +
+ +
+ undo + Undo + ⌘Z +
+ +
+ redo + Redo + ⌘Y +
+ + + +
+ content_cut + Cut + ⌘X +
+ +
+ content_copy + Copy + ⌘C +
+ +
+ content_paste + Paste + ⌘V +
+ + + +
+ find_replace + Find and replace + ⇧⌘H +
+
+
+ +
View
+ +
+ +
+ check + Show print layout +
+ +
+ check + Show ruler +
+ + + +
+ Zoom in + ⌘+ +
+ +
+ Zoom out + ⌘- +
+ + + +
+ Full screen +
+
+
+ +
Insert
+ +
+ +
+ image + Image + arrow_left +
+ +
+ +
+ upload + Upload from computer +
+ +
+ search + Search the web +
+ +
+ link + By URL +
+
+
+ +
+ table_chart + Table +
+ +
+ insert_chart + Chart + arrow_left +
+ +
+ +
+ bar_chart + Bar +
+ +
+ insert_chart + Column +
+ +
+ show_chart + Line +
+ +
+ pie_chart + Pie +
+
+
+ +
+ horizontal_rule + Horizontal line +
+
+
+ +
Format
+ +
+ +
+ format_bold + Text + arrow_left +
+ +
+ +
+ format_bold + Bold + ⌘B +
+ +
+ format_italic + Italic + ⌘I +
+ +
+ format_underlined + Underline + ⌘U +
+ +
+ strikethrough_s + Strikethrough + ⇧⌘X +
+ + + +
+ Size + arrow_left +
+ +
+ +
+ Increase font size + ⇧⌘. +
+ +
+ Decrease font size + ⇧⌘, +
+
+
+
+
+ +
+ format_align_justify + Paragraph styles + arrow_left +
+ +
+ +
Normal text
+
Heading 1
+
Heading 2
+
+
+ +
+ format_indent_increase + Align & indent + arrow_left +
+ +
+ +
+ format_align_left + Align left +
+ +
+ format_align_center + Align center +
+ +
+ format_align_right + Align right +
+ +
+ format_align_justify + Justify +
+
+
+
+
+
diff --git a/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.ts b/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.ts new file mode 100644 index 000000000000..0257896ef7d3 --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar-rtl/menu-bar-rtl-example.ts @@ -0,0 +1,33 @@ +import {Dir} from '@angular/cdk/bidi'; +import {Component} from '@angular/core'; +import { + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemShortcut, + SimpleMenuItemText, +} from '../simple-menu'; +import {MenuContent} from '@angular/aria/menu'; + +/** @title Menu bar RTL example. */ +@Component({ + selector: 'menu-bar-rtl-example', + exportAs: 'MenuBarRTLExample', + templateUrl: 'menu-bar-rtl-example.html', + styleUrl: '../menu-example.css', + standalone: true, + imports: [ + Dir, + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + SimpleMenuItemShortcut, + MenuContent, + ], +}) +export class MenuBarRTLExample {} diff --git a/src/components-examples/aria/menu/menu-example.css b/src/components-examples/aria/menu/menu-example.css index f5fb288f91ec..945ebe261fff 100644 --- a/src/components-examples/aria/menu/menu-example.css +++ b/src/components-examples/aria/menu/menu-example.css @@ -22,6 +22,7 @@ } .example-menu[popover] { + left: auto; position: absolute; } diff --git a/src/components-examples/aria/menu/simple-menu.ts b/src/components-examples/aria/menu/simple-menu.ts index d00a0651854b..98930a997a5d 100644 --- a/src/components-examples/aria/menu/simple-menu.ts +++ b/src/components-examples/aria/menu/simple-menu.ts @@ -28,6 +28,7 @@ export class SimpleMenu { const parentEl = parent.element; const parentRect = parentEl.getBoundingClientRect(); + const menuRect = this.menu.element.getBoundingClientRect(); const scrollX = window.scrollX; const scrollY = window.scrollY; @@ -36,13 +37,16 @@ export class SimpleMenu { const bottom = parentRect.y + scrollY + parentRect.height + 6; if (parent.parent instanceof MenuBar) { - this.menu.element.style.left = `${parentRect.left + scrollX}px`; + const rtlOffset = this.menu.textDirection() === 'rtl' ? menuRect.width - parentRect.width : 0; + this.menu.element.style.left = `${parentRect.left + scrollX - rtlOffset}px`; this.menu.element.style.top = `${bottom}px`; } else if (parent instanceof MenuTrigger) { this.menu.element.style.left = `${parentRect.left + scrollX}px`; this.menu.element.style.top = `${parentRect.bottom + scrollY + 2}px`; } else { - this.menu.element.style.left = `${parentRect.right + scrollX + 6}px`; + const rtlOffset = + this.menu.textDirection() === 'rtl' ? menuRect.width + parentRect.width + 12 : 0; + this.menu.element.style.left = `${parentRect.right + scrollX + 6 - rtlOffset}px`; this.menu.element.style.top = `${top}px`; } } diff --git a/src/dev-app/aria-menu/menu-demo.html b/src/dev-app/aria-menu/menu-demo.html index eb81a19febec..0c9cacad17ac 100644 --- a/src/dev-app/aria-menu/menu-demo.html +++ b/src/dev-app/aria-menu/menu-demo.html @@ -5,6 +5,11 @@

Menu Bar Example

+
+

Menu Bar RTL Example

+ +
+

Menu Trigger Example

diff --git a/src/dev-app/aria-menu/menu-demo.ts b/src/dev-app/aria-menu/menu-demo.ts index 7303be1bb923..fb7ce444eca9 100644 --- a/src/dev-app/aria-menu/menu-demo.ts +++ b/src/dev-app/aria-menu/menu-demo.ts @@ -9,6 +9,7 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; import { MenuBarExample, + MenuBarRTLExample, MenuContextExample, MenuTriggerExample, MenuStandaloneExample, @@ -19,6 +20,12 @@ import { styleUrl: 'menu-demo.css', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MenuBarExample, MenuContextExample, MenuTriggerExample, MenuStandaloneExample], + imports: [ + MenuBarExample, + MenuBarRTLExample, + MenuContextExample, + MenuTriggerExample, + MenuStandaloneExample, + ], }) export class MenuDemo {}