Skip to content

Commit 7e23e34

Browse files
committed
fix(aria/menu): allow menu item role override
1 parent e99ac3b commit 7e23e34

6 files changed

Lines changed: 51 additions & 4 deletions

File tree

goldens/aria/menu/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ export class MenuItem<V> implements OnInit, OnDestroy {
8585
open(): void;
8686
readonly parent: Menu<V> | MenuBar<V> | null;
8787
readonly _pattern: MenuItemPattern<V>;
88+
readonly role: _angular_core.InputSignal<"menuitem" | "menuitemradio" | "menuitemcheckbox">;
8889
readonly searchTerm: _angular_core.ModelSignal<string>;
8990
readonly submenu: _angular_core.InputSignal<Menu<V> | undefined>;
9091
readonly value: _angular_core.InputSignal<V>;
9192
// (undocumented)
92-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuItem<any>, "[ngMenuItem]", ["ngMenuItem"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "searchTerm": { "alias": "searchTerm"; "required": false; "isSignal": true; }; "submenu": { "alias": "submenu"; "required": false; "isSignal": true; }; }, { "searchTerm": "searchTermChange"; }, never, never, true, never>;
93+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuItem<any>, "[ngMenuItem]", ["ngMenuItem"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "searchTerm": { "alias": "searchTerm"; "required": false; "isSignal": true; }; "role": { "alias": "role"; "required": false; "isSignal": true; }; "submenu": { "alias": "submenu"; "required": false; "isSignal": true; }; }, { "searchTerm": "searchTermChange"; }, never, never, true, never>;
9394
// (undocumented)
9495
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MenuItem<any>, never>;
9596
}

goldens/aria/private/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ export interface MenuInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, '
388388
// @public
389389
export interface MenuItemInputs<V> extends Omit<ListItem<V>, 'index' | 'selectable'> {
390390
parent: SignalLike<MenuPattern<V> | MenuBarPattern<V> | undefined>;
391+
role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>;
391392
submenu: SignalLike<MenuPattern<V> | undefined>;
392393
}
393394

@@ -414,7 +415,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
414415
first?: boolean;
415416
last?: boolean;
416417
}): void;
417-
readonly role: () => string;
418+
readonly role: () => "menuitem" | "menuitemradio" | "menuitemcheckbox";
418419
readonly searchTerm: SignalLike<string>;
419420
readonly selectable: SignalLike<boolean>;
420421
readonly submenu: SignalLike<MenuPattern<V> | undefined>;

src/aria/menu/menu-item.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import type {MenuBar} from './menu-bar';
4444
selector: '[ngMenuItem]',
4545
exportAs: 'ngMenuItem',
4646
host: {
47-
'role': 'menuitem',
47+
'[attr.role]': '_pattern.role()',
4848
'(focusin)': '_pattern.onFocusIn()',
4949
'[attr.tabindex]': '_pattern.tabIndex()',
5050
'[attr.data-active]': 'active()',
@@ -73,6 +73,9 @@ export class MenuItem<V> implements OnInit, OnDestroy {
7373
/** The search term associated with the menu item. */
7474
readonly searchTerm = model<string>('');
7575

76+
/** The role of the menu item. */
77+
readonly role = input<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitem');
78+
7679
/** A reference to the parent menu or menubar. */
7780
readonly parent = inject<Menu<V> | MenuBar<V>>(MENU_COMPONENT, {optional: true});
7881

@@ -97,6 +100,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
97100
searchTerm: this.searchTerm,
98101
parent: computed(() => this.parent?._pattern),
99102
submenu: computed(() => this.submenu()?._pattern),
103+
role: this.role,
100104
});
101105

102106
constructor() {

src/aria/menu/menu.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,28 @@ describe('Standalone Menu Pattern', () => {
498498
expect(item?.getAttribute('aria-label')).toBe('Apple item label');
499499
});
500500

501+
describe('role override', () => {
502+
it('should allow overriding the default menuitem role', () => {
503+
TestBed.resetTestingModule();
504+
TestBed.configureTestingModule({
505+
imports: [MenuItemRoleOverrideExample],
506+
});
507+
const roleFixture = TestBed.createComponent(MenuItemRoleOverrideExample);
508+
roleFixture.detectChanges();
509+
510+
const items = roleFixture.debugElement
511+
.queryAll(By.directive(MenuItem))
512+
.map(debugEl => debugEl.nativeElement as HTMLElement);
513+
514+
expect(items[0].getAttribute('role')).toBe('menuitemradio');
515+
expect(items[1].getAttribute('role')).toBe('menuitemcheckbox');
516+
517+
roleFixture.componentInstance.customRole.set('menuitem');
518+
roleFixture.detectChanges();
519+
expect(items[1].getAttribute('role')).toBe('menuitem');
520+
});
521+
});
522+
501523
describe('structural validations', () => {
502524
let consoleSpy: jasmine.Spy;
503525

@@ -1227,3 +1249,17 @@ class MenuWithDuplicateValues {}
12271249
changeDetection: ChangeDetectionStrategy.Eager,
12281250
})
12291251
class MenuItemOutsideMenu {}
1252+
1253+
@Component({
1254+
template: `
1255+
<div ngMenu>
1256+
<div ngMenuItem value="item0" role="menuitemradio">Item 0</div>
1257+
<div ngMenuItem value="item1" [role]="customRole()">Item 1</div>
1258+
</div>
1259+
`,
1260+
imports: [Menu, MenuItem],
1261+
changeDetection: ChangeDetectionStrategy.Eager,
1262+
})
1263+
class MenuItemRoleOverrideExample {
1264+
customRole = signal<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitemcheckbox');
1265+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl
8080
parent: signal(menubar),
8181
element: signal(element),
8282
submenu: signal(undefined),
83+
role: signal('menuitem'),
8384
}) as TestMenuItem;
8485
}),
8586
);
@@ -125,6 +126,7 @@ function getMenuPattern(
125126
parent: signal(menu),
126127
element: signal(element),
127128
submenu: signal(undefined),
129+
role: signal('menuitem'),
128130
}) as TestMenuItem;
129131
}),
130132
);

src/aria/private/menu/menu.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export interface MenuItemInputs<V> extends Omit<ListItem<V>, 'index' | 'selectab
6565

6666
/** A reference to the submenu associated with the menu item. */
6767
submenu: SignalLike<MenuPattern<V> | undefined>;
68+
69+
/** The role of the menu item. */
70+
role: SignalLike<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>;
6871
}
6972

7073
/** The menu ui pattern class. */
@@ -778,7 +781,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
778781
readonly controls = signal<string | undefined>(undefined);
779782

780783
/** The role of the menu item. */
781-
readonly role = () => 'menuitem';
784+
readonly role = () => this.inputs.role();
782785

783786
/** Whether the menu item has a popup. */
784787
readonly hasPopup = computed(() => !!this.submenu());

0 commit comments

Comments
 (0)