Skip to content

Commit 30571ec

Browse files
fix(aria/menu): accept a bare disabled attribute on MenuItem
MenuItem.disabled was declared as input<boolean>(false), so <div ngMenuItem disabled> failed strict template type-checking and was not disabled in JIT. Use booleanAttribute, like the disabled inputs on the other aria directives. Menu items are only soft-disabled, so also clear the native disabled attribute on the host. Otherwise a bare disabled on a <button> host would hard-disable it and take it out of keyboard navigation. Fixes #33964
1 parent 7360f0b commit 30571ec

3 files changed

Lines changed: 71 additions & 2 deletions

File tree

‎goldens/aria/menu/index.api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
7878
constructor();
7979
readonly active: _angular_core.Signal<boolean>;
8080
close(): void;
81-
readonly disabled: _angular_core.InputSignal<boolean>;
81+
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
8282
readonly element: HTMLElement;
8383
readonly expanded: _angular_core.Signal<boolean | null>;
8484
readonly hasPopup: _angular_core.Signal<boolean>;

‎src/aria/menu/menu-item.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {
10+
booleanAttribute,
1011
computed,
1112
Directive,
1213
effect,
@@ -51,6 +52,9 @@ import type {MenuBar} from './menu-bar';
5152
'[attr.aria-haspopup]': 'hasPopup()',
5253
'[attr.aria-expanded]': 'expanded()',
5354
'[attr.aria-disabled]': '_pattern.disabled()',
55+
// Menu items are only ever soft-disabled. Clear the native attribute so a bare
56+
// `disabled` on a `<button>` host doesn't make the item unfocusable.
57+
'[attr.disabled]': 'null',
5458
'[attr.aria-controls]': '_pattern.submenu()?.id()',
5559
},
5660
})
@@ -68,7 +72,7 @@ export class MenuItem<V> implements OnInit, OnDestroy {
6872
readonly value = input<V | undefined>(undefined);
6973

7074
/** Whether the menu item is disabled. */
71-
readonly disabled = input<boolean>(false);
75+
readonly disabled = input(false, {transform: booleanAttribute});
7276

7377
/** The search term associated with the menu item. */
7478
readonly searchTerm = model<string>('');

‎src/aria/menu/menu.spec.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,46 @@ describe('Standalone Menu Pattern', () => {
556556
});
557557
});
558558

559+
describe('disabled attribute', () => {
560+
it('should treat a bare disabled attribute as disabled', () => {
561+
TestBed.resetTestingModule();
562+
TestBed.configureTestingModule({
563+
imports: [MenuItemDisabledAttributeExample],
564+
});
565+
const disabledFixture = TestBed.createComponent(MenuItemDisabledAttributeExample);
566+
disabledFixture.detectChanges();
567+
568+
const items = disabledFixture.debugElement
569+
.queryAll(By.directive(MenuItem))
570+
.map(debugEl => debugEl.nativeElement as HTMLElement);
571+
572+
expect(items[0].getAttribute('aria-disabled')).toBe('false');
573+
expect(items[1].getAttribute('aria-disabled')).toBe('true');
574+
});
575+
576+
it('should keep a disabled button item soft-disabled and focusable', async () => {
577+
TestBed.resetTestingModule();
578+
TestBed.configureTestingModule({
579+
imports: [MenuItemDisabledButtonExample],
580+
});
581+
const disabledFixture = TestBed.createComponent(MenuItemDisabledButtonExample);
582+
await disabledFixture.whenStable();
583+
584+
const items = disabledFixture.debugElement
585+
.queryAll(By.directive(MenuItem))
586+
.map(debugEl => debugEl.nativeElement as HTMLButtonElement);
587+
588+
expect(items[1].hasAttribute('disabled')).toBe(false);
589+
expect(items[1].disabled).toBe(false);
590+
expect(items[1].getAttribute('aria-disabled')).toBe('true');
591+
592+
items[0].focus();
593+
items[0].dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}));
594+
await disabledFixture.whenStable();
595+
expect(document.activeElement).toBe(items[1]);
596+
});
597+
});
598+
559599
describe('structural validations', () => {
560600
let consoleSpy: jasmine.Spy;
561601

@@ -1486,3 +1526,28 @@ class CdkOverlayMenuExample {
14861526
class MenuItemRoleOverrideExample {
14871527
customRole = signal<'menuitem' | 'menuitemradio' | 'menuitemcheckbox'>('menuitemcheckbox');
14881528
}
1529+
1530+
@Component({
1531+
template: `
1532+
<div ngMenu>
1533+
<div ngMenuItem value="item0">Item 0</div>
1534+
<div ngMenuItem value="item1" disabled>Item 1</div>
1535+
</div>
1536+
`,
1537+
imports: [Menu, MenuItem],
1538+
changeDetection: ChangeDetectionStrategy.Eager,
1539+
})
1540+
class MenuItemDisabledAttributeExample {}
1541+
1542+
@Component({
1543+
template: `
1544+
<div ngMenu>
1545+
<button ngMenuItem value="item0">Item 0</button>
1546+
<button ngMenuItem value="item1" disabled>Item 1</button>
1547+
<button ngMenuItem value="item2">Item 2</button>
1548+
</div>
1549+
`,
1550+
imports: [Menu, MenuItem],
1551+
changeDetection: ChangeDetectionStrategy.Eager,
1552+
})
1553+
class MenuItemDisabledButtonExample {}

0 commit comments

Comments
 (0)