Skip to content

Commit 969a9ab

Browse files
authored
fix(aria/menu): rtl text direction (#32254)
* fix(aria/menu): rtl text direction * docs(aria/menu): rtl menu bar example
1 parent edd36b2 commit 969a9ab

File tree

12 files changed

+562
-30
lines changed

12 files changed

+562
-30
lines changed

src/aria/menu/menu.spec.ts

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Component, DebugElement} from '@angular/core';
22
import {ComponentFixture, TestBed} from '@angular/core/testing';
33
import {By} from '@angular/platform-browser';
44
import {Menu, MenuBar, MenuItem, MenuTrigger} from './menu';
5+
import {provideFakeDirectionality} from '@angular/cdk/testing/private';
56

67
describe('Standalone Menu Pattern', () => {
78
let fixture: ComponentFixture<StandaloneMenuExample>;
@@ -37,8 +38,10 @@ describe('Standalone Menu Pattern', () => {
3738
fixture.detectChanges();
3839
};
3940

40-
function setupMenu() {
41-
TestBed.configureTestingModule({});
41+
function setupMenu(opts?: {textDirection: 'ltr' | 'rtl'}) {
42+
TestBed.configureTestingModule({
43+
providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')],
44+
});
4245
fixture = TestBed.createComponent(StandaloneMenuExample);
4346
fixture.detectChanges();
4447
getItem('Apple')?.focus();
@@ -407,6 +410,44 @@ describe('Standalone Menu Pattern', () => {
407410
expect(isSubmenuExpanded()).toBe(true);
408411
});
409412
});
413+
414+
describe('RTL', () => {
415+
function isSubmenuExpanded(): boolean {
416+
const berries = getItem('Berries');
417+
return berries?.getAttribute('aria-expanded') === 'true';
418+
}
419+
420+
beforeEach(() => setupMenu({textDirection: 'rtl'}));
421+
422+
it('should open submenu on arrow left', () => {
423+
const apple = getItem('Apple');
424+
const banana = getItem('Banana');
425+
const berries = getItem('Berries');
426+
const blueberry = getItem('Blueberry');
427+
428+
keydown(apple!, 'ArrowDown');
429+
keydown(banana!, 'ArrowDown');
430+
keydown(berries!, 'ArrowLeft');
431+
432+
expect(isSubmenuExpanded()).toBe(true);
433+
expect(document.activeElement).toBe(blueberry);
434+
});
435+
436+
it('should close submenu on arrow right', () => {
437+
const apple = getItem('Apple');
438+
const banana = getItem('Banana');
439+
const berries = getItem('Berries');
440+
const blueberry = getItem('Blueberry');
441+
442+
keydown(apple!, 'ArrowDown');
443+
keydown(banana!, 'ArrowDown');
444+
keydown(berries!, 'ArrowLeft');
445+
keydown(blueberry!, 'ArrowRight');
446+
447+
expect(isSubmenuExpanded()).toBe(false);
448+
expect(document.activeElement).toBe(berries);
449+
});
450+
});
410451
});
411452

412453
describe('Menu Trigger Pattern', () => {
@@ -601,8 +642,10 @@ describe('Menu Bar Pattern', () => {
601642
fixture.detectChanges();
602643
};
603644

604-
function setupMenu() {
605-
TestBed.configureTestingModule({});
645+
function setupMenu(opts?: {textDirection: 'ltr' | 'rtl'}) {
646+
TestBed.configureTestingModule({
647+
providers: [provideFakeDirectionality(opts?.textDirection ?? 'ltr')],
648+
});
606649
fixture = TestBed.createComponent(MenuBarExample);
607650
fixture.detectChanges();
608651
getMenuBarItem('File')?.focus();
@@ -634,10 +677,7 @@ describe('Menu Bar Pattern', () => {
634677
}
635678

636679
describe('Navigation', () => {
637-
beforeEach(() => {
638-
setupMenu();
639-
getMenuBarItem('File')?.focus();
640-
});
680+
beforeEach(() => setupMenu());
641681

642682
it('should navigate to the first item on arrow down', () => {
643683
const file = getMenuBarItem('File');
@@ -866,6 +906,40 @@ describe('Menu Bar Pattern', () => {
866906
expect(isExpanded('Edit')).toBe(true);
867907
});
868908
});
909+
910+
describe('RTL', () => {
911+
beforeEach(() => setupMenu({textDirection: 'rtl'}));
912+
913+
it('should focus the first item of the next menubar item on arrow left', () => {
914+
const edit = getMenuBarItem('Edit');
915+
const file = getMenuBarItem('File');
916+
const view = getMenuBarItem('View');
917+
const documentation = getMenuBarItem('Documentation');
918+
const zoomIn = getMenuItem('Zoom In');
919+
920+
keydown(file!, 'ArrowLeft');
921+
keydown(edit!, 'ArrowLeft');
922+
keydown(view!, 'ArrowDown');
923+
924+
keydown(zoomIn!, 'ArrowLeft');
925+
expect(document.activeElement).toBe(documentation);
926+
});
927+
928+
it('should focus the first item of the previous menubar item on arrow right', () => {
929+
const edit = getMenuBarItem('Edit');
930+
const file = getMenuBarItem('File');
931+
const view = getMenuBarItem('View');
932+
const undo = getMenuItem('Undo');
933+
const zoomIn = getMenuItem('Zoom In');
934+
935+
keydown(file!, 'ArrowLeft');
936+
keydown(edit!, 'ArrowLeft');
937+
keydown(view!, 'ArrowDown');
938+
939+
keydown(zoomIn!, 'ArrowRight');
940+
expect(document.activeElement).toBe(undo);
941+
});
942+
});
869943
});
870944

871945
@Component({

src/aria/menu/menu.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
DeferredContentAware,
3232
} from '@angular/aria/private';
3333
import {_IdGenerator} from '@angular/cdk/a11y';
34-
import {toSignal} from '@angular/core/rxjs-interop';
3534
import {Directionality} from '@angular/cdk/bidi';
3635

3736
/**
@@ -59,11 +58,12 @@ export class MenuTrigger<V> {
5958
/** A reference to the menu trigger element. */
6059
private readonly _elementRef = inject(ElementRef);
6160

61+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
62+
readonly textDirection = inject(Directionality).valueSignal;
63+
6264
/** A reference to the menu element. */
6365
readonly element: HTMLButtonElement = this._elementRef.nativeElement;
6466

65-
// TODO(wagnermaciel): See we can remove the need to pass in a submenu.
66-
6767
/** The menu associated with the trigger. */
6868
menu = input<Menu<V> | undefined>(undefined);
6969

@@ -72,6 +72,7 @@ export class MenuTrigger<V> {
7272

7373
/** The menu trigger ui pattern instance. */
7474
_pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
75+
textDirection: this.textDirection,
7576
element: computed(() => this._elementRef.nativeElement),
7677
menu: computed(() => this.menu()?._pattern),
7778
});
@@ -143,12 +144,7 @@ export class Menu<V> {
143144
readonly element: HTMLElement = this._elementRef.nativeElement;
144145

145146
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
146-
private readonly _directionality = inject(Directionality);
147-
148-
/** A signal wrapper for directionality. */
149-
readonly textDirection = toSignal(this._directionality.change, {
150-
initialValue: this._directionality.value,
151-
});
147+
readonly textDirection = inject(Directionality).valueSignal;
152148

153149
/** The unique ID of the menu. */
154150
readonly id = input<string>(inject(_IdGenerator).getId('ng-menu-', true));
@@ -278,12 +274,7 @@ export class MenuBar<V> {
278274
readonly element: HTMLElement = this._elementRef.nativeElement;
279275

280276
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
281-
private readonly _directionality = inject(Directionality);
282-
283-
/** A signal wrapper for directionality. */
284-
readonly textDirection = toSignal(this._directionality.change, {
285-
initialValue: this._directionality.value,
286-
});
277+
readonly textDirection = inject(Directionality).valueSignal;
287278

288279
/** The value of the menu. */
289280
readonly value = model<V[]>([]);

src/aria/private/menu/menu.spec.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,25 @@ function clickMenuItem(items: MenuItemPattern<any>[], index: number, mods?: Modi
3636
} as unknown as PointerEvent;
3737
}
3838

39-
function getMenuTriggerPattern() {
39+
function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) {
4040
const element = signal(document.createElement('button'));
4141
const submenu = signal<MenuPattern<string> | undefined>(undefined);
4242
const trigger = new MenuTriggerPattern<string>({
43+
textDirection: signal(opts?.textDirection || 'ltr'),
4344
element,
4445
menu: submenu,
4546
});
4647
return trigger;
4748
}
4849

49-
function getMenuBarPattern(values: string[]) {
50+
function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl'}) {
5051
const items = signal<TestMenuItem[]>([]);
5152

5253
const menubar = new MenuBarPattern<string>({
5354
items: items,
5455
activeItem: signal(undefined),
5556
orientation: signal('horizontal'),
56-
textDirection: signal('ltr'),
57+
textDirection: signal(opts?.textDirection || 'ltr'),
5758
multi: signal(false),
5859
selectionMode: signal('explicit'),
5960
value: signal([]),
@@ -86,6 +87,7 @@ function getMenuBarPattern(values: string[]) {
8687
function getMenuPattern(
8788
parent: undefined | MenuItemPattern<string> | MenuTriggerPattern<string>,
8889
values: string[],
90+
opts?: {textDirection: 'ltr' | 'rtl'},
8991
) {
9092
const items = signal<TestMenuItem[]>([]);
9193

@@ -99,7 +101,7 @@ function getMenuPattern(
99101
softDisabled: signal(true),
100102
multi: signal(false),
101103
focusMode: signal('activedescendant'),
102-
textDirection: signal('ltr'),
104+
textDirection: signal(opts?.textDirection || 'ltr'),
103105
orientation: signal('vertical'),
104106
selectionMode: signal('explicit'),
105107
element: signal(document.createElement('div')),
@@ -409,6 +411,27 @@ describe('Standalone Menu Pattern', () => {
409411
expect(submenu.isVisible()).toBe(true);
410412
});
411413
});
414+
415+
describe('RTL', () => {
416+
beforeEach(() => {
417+
const opts = {textDirection: 'rtl' as const};
418+
menu = getMenuPattern(undefined, ['a', 'b', 'c'], opts);
419+
submenu = getMenuPattern(menu.inputs.items()[0], ['d', 'e'], opts);
420+
});
421+
422+
it('should open submenu on arrow left', () => {
423+
menu.onKeydown(left());
424+
expect(submenu.isVisible()).toBe(true);
425+
});
426+
427+
it('should close submenu on arrow right', () => {
428+
menu.onKeydown(left());
429+
expect(submenu.isVisible()).toBe(true);
430+
431+
submenu.onKeydown(right());
432+
expect(submenu.isVisible()).toBe(false);
433+
});
434+
});
412435
});
413436

414437
describe('Menu Trigger Pattern', () => {
@@ -830,5 +853,59 @@ describe('Menu Bar Pattern', () => {
830853
expect(menubarItems[0].expanded()).toBe(true);
831854
expect(menubar.inputs.activeItem()).toBe(menubarItems[0]);
832855
});
856+
857+
describe('RTL', () => {
858+
beforeEach(() => {
859+
const opts = {textDirection: 'rtl' as const};
860+
menubar = getMenuBarPattern(['a', 'b', 'c'], opts);
861+
menuA = getMenuPattern(menubar.inputs.items()[0], ['apple', 'avocado'], opts);
862+
menuB = getMenuPattern(menubar.inputs.items()[1], ['banana', 'blueberry'], opts);
863+
menuC = getMenuPattern(menubar.inputs.items()[2], ['cherry', 'cranberry'], opts);
864+
});
865+
866+
it('should close on arrow left on a leaf menu item', () => {
867+
const menubarItems = menubar.inputs.items();
868+
menubar.onClick(clickMenuItem(menubarItems, 0));
869+
expect(menuA.isVisible()).toBe(true);
870+
871+
menuA.onKeydown(left());
872+
873+
expect(menuA.isVisible()).toBe(false);
874+
expect(menubarItems[0].expanded()).toBe(false);
875+
});
876+
877+
it('should close on arrow right on a root menu item', () => {
878+
const menubarItems = menubar.inputs.items();
879+
menubar.onClick(clickMenuItem(menubarItems, 1));
880+
expect(menuB.isVisible()).toBe(true);
881+
882+
menuB.onKeydown(right());
883+
884+
expect(menuB.isVisible()).toBe(false);
885+
expect(menubarItems[1].expanded()).toBe(false);
886+
});
887+
888+
it('should expand the next menu bar item on arrow left on a leaf menu item', () => {
889+
const menubarItems = menubar.inputs.items();
890+
menubar.onClick(clickMenuItem(menubarItems, 0));
891+
892+
menuA.onKeydown(left());
893+
894+
expect(menuB.isVisible()).toBe(true);
895+
expect(menubarItems[1].expanded()).toBe(true);
896+
expect(menubar.inputs.activeItem()).toBe(menubarItems[1]);
897+
});
898+
899+
it('should expand the previous menu bar item on arrow right on a root menu item', () => {
900+
const menubarItems = menubar.inputs.items();
901+
menubar.onClick(clickMenuItem(menubarItems, 1));
902+
903+
menuB.onKeydown(right());
904+
905+
expect(menuA.isVisible()).toBe(true);
906+
expect(menubarItems[0].expanded()).toBe(true);
907+
expect(menubar.inputs.activeItem()).toBe(menubarItems[0]);
908+
});
909+
});
833910
});
834911
});

src/aria/private/menu/menu.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>
1818

1919
/** Callback function triggered when a menu item is selected. */
2020
onSelect?: (value: V) => void;
21+
22+
/** The text direction of the menu bar. */
23+
textDirection: SignalLike<'ltr' | 'rtl'>;
2124
}
2225

2326
/** The inputs for the MenuPattern class. */
@@ -34,6 +37,9 @@ export interface MenuInputs<V>
3437

3538
/** Callback function triggered when a menu item is selected. */
3639
onSelect?: (value: V) => void;
40+
41+
/** The text direction of the menu bar. */
42+
textDirection: SignalLike<'ltr' | 'rtl'>;
3743
}
3844

3945
/** The inputs for the MenuTriggerPattern class. */
@@ -43,6 +49,9 @@ export interface MenuTriggerInputs<V> {
4349

4450
/** A reference to the menu associated with the trigger. */
4551
menu: SignalLike<MenuPattern<V> | undefined>;
52+
53+
/** The text direction of the menu bar. */
54+
textDirection: SignalLike<'ltr' | 'rtl'>;
4655
}
4756

4857
/** The inputs for the MenuItemPattern class. */

src/components-examples/aria/menu/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ng_project(
1212
deps = [
1313
"//:node_modules/@angular/core",
1414
"//src/aria/menu",
15+
"//src/cdk/a11y",
1516
],
1617
)
1718

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {MenuBarExample} from './menu-bar/menu-bar-example';
2+
export {MenuBarRTLExample} from './menu-bar-rtl/menu-bar-rtl-example';
23
export {MenuContextExample} from './menu-context/menu-context-example';
34
export {MenuTriggerExample} from './menu-trigger/menu-trigger-example';
45
export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example';

0 commit comments

Comments
 (0)