Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 82 additions & 8 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StandaloneMenuExample>;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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({
Expand Down
21 changes: 6 additions & 15 deletions src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -59,11 +58,12 @@ export class MenuTrigger<V> {
/** 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<Menu<V> | undefined>(undefined);

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

/** The menu trigger ui pattern instance. */
_pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
textDirection: this.textDirection,
element: computed(() => this._elementRef.nativeElement),
menu: computed(() => this.menu()?._pattern),
});
Expand Down Expand Up @@ -143,12 +144,7 @@ export class Menu<V> {
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<string>(inject(_IdGenerator).getId('ng-menu-', true));
Expand Down Expand Up @@ -278,12 +274,7 @@ export class MenuBar<V> {
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<V[]>([]);
Expand Down
85 changes: 81 additions & 4 deletions src/aria/private/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,25 @@ function clickMenuItem(items: MenuItemPattern<any>[], 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<MenuPattern<string> | undefined>(undefined);
const trigger = new MenuTriggerPattern<string>({
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<TestMenuItem[]>([]);

const menubar = new MenuBarPattern<string>({
items: items,
activeItem: signal(undefined),
orientation: signal('horizontal'),
textDirection: signal('ltr'),
textDirection: signal(opts?.textDirection || 'ltr'),
multi: signal(false),
selectionMode: signal('explicit'),
value: signal([]),
Expand Down Expand Up @@ -86,6 +87,7 @@ function getMenuBarPattern(values: string[]) {
function getMenuPattern(
parent: undefined | MenuItemPattern<string> | MenuTriggerPattern<string>,
values: string[],
opts?: {textDirection: 'ltr' | 'rtl'},
) {
const items = signal<TestMenuItem[]>([]);

Expand All @@ -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')),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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]);
});
});
});
});
9 changes: 9 additions & 0 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, 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. */
Expand All @@ -34,6 +37,9 @@ export interface MenuInputs<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 MenuTriggerPattern class. */
Expand All @@ -43,6 +49,9 @@ export interface MenuTriggerInputs<V> {

/** A reference to the menu associated with the trigger. */
menu: SignalLike<MenuPattern<V> | undefined>;

/** The text direction of the menu bar. */
textDirection: SignalLike<'ltr' | 'rtl'>;
}

/** The inputs for the MenuItemPattern class. */
Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ng_project(
deps = [
"//:node_modules/@angular/core",
"//src/aria/menu",
"//src/cdk/a11y",
],
)

Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading