Skip to content

Commit 50e281a

Browse files
authored
fix(aria/menu): allow menu item role override (#33264)
1 parent 629fe71 commit 50e281a

6 files changed

Lines changed: 52 additions & 5 deletions

File tree

goldens/aria/menu/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,12 @@ export class MenuItem<V> implements OnInit, OnDestroy {
8686
open(): void;
8787
readonly parent: Menu<V> | MenuBar<V> | null;
8888
readonly _pattern: MenuItemPattern<V>;
89+
readonly role: _angular_core.InputSignal<"menuitem" | "menuitemradio" | "menuitemcheckbox">;
8990
readonly searchTerm: _angular_core.ModelSignal<string>;
9091
readonly submenu: _angular_core.InputSignal<Menu<V> | undefined>;
9192
readonly value: _angular_core.InputSignal<V>;
9293
// (undocumented)
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; }; "submenu": { "alias": "submenu"; "required": false; "isSignal": true; }; }, { "searchTerm": "searchTermChange"; }, never, never, true, never>;
94+
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>;
9495
// (undocumented)
9596
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MenuItem<any>, never>;
9697
}

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: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,28 @@ describe('Standalone Menu Pattern', () => {
534534
});
535535
});
536536

537+
describe('role override', () => {
538+
it('should allow overriding the default menuitem role', () => {
539+
TestBed.resetTestingModule();
540+
TestBed.configureTestingModule({
541+
imports: [MenuItemRoleOverrideExample],
542+
});
543+
const roleFixture = TestBed.createComponent(MenuItemRoleOverrideExample);
544+
roleFixture.detectChanges();
545+
546+
const items = roleFixture.debugElement
547+
.queryAll(By.directive(MenuItem))
548+
.map(debugEl => debugEl.nativeElement as HTMLElement);
549+
550+
expect(items[0].getAttribute('role')).toBe('menuitemradio');
551+
expect(items[1].getAttribute('role')).toBe('menuitemcheckbox');
552+
553+
roleFixture.componentInstance.customRole.set('menuitem');
554+
roleFixture.detectChanges();
555+
expect(items[1].getAttribute('role')).toBe('menuitem');
556+
});
557+
});
558+
537559
describe('structural validations', () => {
538560
let consoleSpy: jasmine.Spy;
539561

@@ -1372,8 +1394,8 @@ class MenuItemOutsideMenu {}
13721394
13731395
<ng-template
13741396
cdkConnectedOverlay
1375-
[cdkConnectedOverlayOrigin]="origin"
13761397
[cdkConnectedOverlayOpen]="menuTrigger.expanded()"
1398+
[cdkConnectedOverlayOrigin]="origin"
13771399
>
13781400
<div ngMenu #overlayMenu="ngMenu">
13791401
<ng-template ngMenuContent>
@@ -1404,3 +1426,17 @@ class CdkOverlayMenuExample {
14041426
this._cdr.markForCheck();
14051427
}
14061428
}
1429+
1430+
@Component({
1431+
template: `
1432+
<div ngMenu>
1433+
<div ngMenuItem value="item0" role="menuitemradio">Item 0</div>
1434+
<div ngMenuItem value="item1" [role]="customRole()">Item 1</div>
1435+
</div>
1436+
`,
1437+
imports: [Menu, MenuItem],
1438+
changeDetection: ChangeDetectionStrategy.Eager,
1439+
})
1440+
class MenuItemRoleOverrideExample {
1441+
customRole = signal<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitemcheckbox');
1442+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl
9393
parent: signal(menubar),
9494
element: signal(element),
9595
submenu: signal(undefined),
96+
role: signal('menuitem'),
9697
}) as TestMenuItem;
9798
}),
9899
);
@@ -138,6 +139,7 @@ function getMenuPattern(
138139
parent: signal(menu),
139140
element: signal(element),
140141
submenu: signal(undefined),
142+
role: signal('menuitem'),
141143
}) as TestMenuItem;
142144
}),
143145
);

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. */
@@ -796,7 +799,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
796799
readonly controls = signal<string | undefined>(undefined);
797800

798801
/** The role of the menu item. */
799-
readonly role = () => 'menuitem';
802+
readonly role = () => this.inputs.role();
800803

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

0 commit comments

Comments
 (0)