Skip to content

Commit b1f2d55

Browse files
committed
fix(aria/menu): allow menu item role override
1 parent 905aeb5 commit b1f2d55

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
@@ -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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,28 @@ describe('Standalone Menu Pattern', () => {
524524
});
525525
});
526526

527+
describe('role override', () => {
528+
it('should allow overriding the default menuitem role', () => {
529+
TestBed.resetTestingModule();
530+
TestBed.configureTestingModule({
531+
imports: [MenuItemRoleOverrideExample],
532+
});
533+
const roleFixture = TestBed.createComponent(MenuItemRoleOverrideExample);
534+
roleFixture.detectChanges();
535+
536+
const items = roleFixture.debugElement
537+
.queryAll(By.directive(MenuItem))
538+
.map(debugEl => debugEl.nativeElement as HTMLElement);
539+
540+
expect(items[0].getAttribute('role')).toBe('menuitemradio');
541+
expect(items[1].getAttribute('role')).toBe('menuitemcheckbox');
542+
543+
roleFixture.componentInstance.customRole.set('menuitem');
544+
roleFixture.detectChanges();
545+
expect(items[1].getAttribute('role')).toBe('menuitem');
546+
});
547+
});
548+
527549
describe('structural validations', () => {
528550
let consoleSpy: jasmine.Spy;
529551

@@ -1254,3 +1276,17 @@ class MenuWithDuplicateValues {}
12541276
changeDetection: ChangeDetectionStrategy.Eager,
12551277
})
12561278
class MenuItemOutsideMenu {}
1279+
1280+
@Component({
1281+
template: `
1282+
<div ngMenu>
1283+
<div ngMenuItem value="item0" role="menuitemradio">Item 0</div>
1284+
<div ngMenuItem value="item1" [role]="customRole()">Item 1</div>
1285+
</div>
1286+
`,
1287+
imports: [Menu, MenuItem],
1288+
changeDetection: ChangeDetectionStrategy.Eager,
1289+
})
1290+
class MenuItemRoleOverrideExample {
1291+
customRole = signal<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitemcheckbox');
1292+
}

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)