Skip to content

Commit 03b0def

Browse files
authored
fix(aria/menu): disabled state (angular#32301)
* fix(aria/menu): disabled state * fixup! fix(aria/menu): disabled state * fixup! fix(aria/menu): disabled state
1 parent 56304a3 commit 03b0def

File tree

14 files changed

+625
-27
lines changed

14 files changed

+625
-27
lines changed

src/aria/menu/menu.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {
1010
afterRenderEffect,
11+
booleanAttribute,
1112
computed,
1213
contentChildren,
1314
Directive,
@@ -47,6 +48,8 @@ import {Directionality} from '@angular/cdk/bidi';
4748
host: {
4849
'class': 'ng-menu-trigger',
4950
'[attr.tabindex]': '_pattern.tabIndex()',
51+
'[attr.disabled]': '!softDisabled() && _pattern.disabled() ? true : null',
52+
'[attr.aria-disabled]': '_pattern.disabled()',
5053
'[attr.aria-haspopup]': 'hasPopup()',
5154
'[attr.aria-expanded]': 'expanded()',
5255
'[attr.aria-controls]': '_pattern.menu()?.id()',
@@ -75,11 +78,18 @@ export class MenuTrigger<V> {
7578
/** Whether the menu trigger has a popup. */
7679
readonly hasPopup = computed(() => this._pattern.hasPopup());
7780

81+
/** Whether the menu trigger is disabled. */
82+
readonly disabled = input(false, {transform: booleanAttribute});
83+
84+
/** Whether the menu trigger is soft disabled. */
85+
readonly softDisabled = input(true, {transform: booleanAttribute});
86+
7887
/** The menu trigger ui pattern instance. */
7988
_pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
8089
textDirection: this.textDirection,
8190
element: computed(() => this._elementRef.nativeElement),
8291
menu: computed(() => this.menu()?._pattern),
92+
disabled: () => this.disabled(),
8393
});
8494

8595
constructor() {
@@ -122,6 +132,8 @@ export class MenuTrigger<V> {
122132
'role': 'menu',
123133
'class': 'ng-menu',
124134
'[attr.id]': '_pattern.id()',
135+
'[attr.aria-disabled]': '_pattern.disabled()',
136+
'[attr.tabindex]': 'tabIndex()',
125137
'[attr.data-visible]': 'isVisible()',
126138
'(keydown)': '_pattern.onKeydown($event)',
127139
'(mouseover)': '_pattern.onMouseOver($event)',
@@ -162,11 +174,14 @@ export class Menu<V> {
162174
readonly id = input<string>(inject(_IdGenerator).getId('ng-menu-', true));
163175

164176
/** Whether the menu should wrap its items. */
165-
readonly wrap = input<boolean>(true);
177+
readonly wrap = input(true, {transform: booleanAttribute});
166178

167179
/** The delay in seconds before the typeahead buffer is cleared. */
168180
readonly typeaheadDelay = input<number>(0.5); // Picked arbitrarily.
169181

182+
/** Whether the menu is disabled. */
183+
readonly disabled = input(false, {transform: booleanAttribute});
184+
170185
/** A reference to the parent menu item or menu trigger. */
171186
readonly parent = signal<MenuTrigger<V> | MenuItem<V> | undefined>(undefined);
172187

@@ -185,11 +200,14 @@ export class Menu<V> {
185200
/** Whether the menu is visible. */
186201
readonly isVisible = computed(() => this._pattern.isVisible());
187202

203+
/** The tab index of the menu. */
204+
readonly tabIndex = computed(() => this._pattern.tabIndex());
205+
188206
/** A callback function triggered when a menu item is selected. */
189207
onSelect = output<V>();
190208

191-
/** The delay in milliseconds before expanding sub-menus on hover. */
192-
readonly expansionDelay = input<number>(150); // Arbitrarily chosen.
209+
/** The delay in seconds before expanding sub-menus on hover. */
210+
readonly expansionDelay = input<number>(0.1); // Arbitrarily chosen.
193211

194212
constructor() {
195213
this._pattern = new MenuPattern({
@@ -228,7 +246,7 @@ export class Menu<V> {
228246
});
229247

230248
afterRenderEffect(() => {
231-
if (!this._pattern.hasBeenFocused()) {
249+
if (!this._pattern.hasBeenFocused() && this._items().length) {
232250
untracked(() => this._pattern.setDefaultState());
233251
}
234252
});
@@ -255,6 +273,9 @@ export class Menu<V> {
255273
host: {
256274
'role': 'menubar',
257275
'class': 'ng-menu-bar',
276+
'[attr.disabled]': '!softDisabled() && _pattern.disabled() ? true : null',
277+
'[attr.aria-disabled]': '_pattern.disabled()',
278+
'[attr.tabindex]': '_pattern.tabIndex()',
258279
'(keydown)': '_pattern.onKeydown($event)',
259280
'(mouseover)': '_pattern.onMouseOver($event)',
260281
'(click)': '_pattern.onClick($event)',
@@ -275,14 +296,20 @@ export class MenuBar<V> {
275296
/** A reference to the menubar element. */
276297
readonly element: HTMLElement = this._elementRef.nativeElement;
277298

299+
/** Whether the menubar is disabled. */
300+
readonly disabled = input(false, {transform: booleanAttribute});
301+
302+
/** Whether the menubar is soft disabled. */
303+
readonly softDisabled = input(true, {transform: booleanAttribute});
304+
278305
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
279306
readonly textDirection = inject(Directionality).valueSignal;
280307

281308
/** The values of the menu. */
282309
readonly values = model<V[]>([]);
283310

284311
/** Whether the menu should wrap its items. */
285-
readonly wrap = input<boolean>(true);
312+
readonly wrap = input(true, {transform: booleanAttribute});
286313

287314
/** The delay in seconds before the typeahead buffer is cleared. */
288315
readonly typeaheadDelay = input<number>(0.5);

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {ModifierKeys} from '@angular/cdk/testing';
1313

1414
// Test types
1515
type TestMenuItem = MenuItemPattern<string> & {
16-
disabled: WritableSignal<boolean>;
17-
submenu: WritableSignal<MenuPattern<string> | undefined>;
16+
inputs: {
17+
disabled: WritableSignal<boolean>;
18+
};
1819
};
1920

2021
// Keyboard event helpers
@@ -43,6 +44,7 @@ function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) {
4344
textDirection: signal(opts?.textDirection || 'ltr'),
4445
element,
4546
menu: submenu,
47+
disabled: signal(false),
4648
});
4749
return trigger;
4850
}
@@ -63,6 +65,7 @@ function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl
6365
softDisabled: signal(true),
6466
focusMode: signal('activedescendant'),
6567
element: signal(document.createElement('div')),
68+
disabled: signal(false),
6669
});
6770

6871
items.set(
@@ -106,6 +109,7 @@ function getMenuPattern(
106109
selectionMode: signal('explicit'),
107110
element: signal(document.createElement('div')),
108111
expansionDelay: signal(0),
112+
disabled: signal(false),
109113
});
110114

111115
items.set(
@@ -275,7 +279,7 @@ describe('Standalone Menu Pattern', () => {
275279

276280
it('should not select a disabled item', () => {
277281
const items = menu.inputs.items() as TestMenuItem[];
278-
items[1].disabled.set(true);
282+
items[1].inputs.disabled.set(true);
279283
menu.inputs.activeItem.set(items[1]);
280284
menu.inputs.onSelect = jasmine.createSpy('onSelect');
281285

src/aria/private/menu/menu.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like';
1212
import {List, ListInputs, ListItem} from '../behaviors/list/list';
1313

1414
/** The inputs for the MenuBarPattern class. */
15-
export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, 'disabled'> {
15+
export interface MenuBarInputs<V> extends ListInputs<MenuItemPattern<V>, V> {
1616
/** The menu items contained in the menu. */
1717
items: SignalLike<MenuItemPattern<V>[]>;
1818

@@ -24,8 +24,7 @@ export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>
2424
}
2525

2626
/** The inputs for the MenuPattern class. */
27-
export interface MenuInputs<V>
28-
extends Omit<ListInputs<MenuItemPattern<V>, V>, 'values' | 'disabled'> {
27+
export interface MenuInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, 'values'> {
2928
/** The unique ID of the menu. */
3029
id: SignalLike<string>;
3130

@@ -55,6 +54,9 @@ export interface MenuTriggerInputs<V> {
5554

5655
/** The text direction of the menu bar. */
5756
textDirection: SignalLike<'ltr' | 'rtl'>;
57+
58+
/** Whether the menu trigger is disabled. */
59+
disabled: SignalLike<boolean>;
5860
}
5961

6062
/** The inputs for the MenuItemPattern class. */
@@ -74,6 +76,9 @@ export class MenuPattern<V> {
7476
/** The role of the menu. */
7577
role = () => 'menu';
7678

79+
/** Whether the menu is disabled. */
80+
disabled = () => this.inputs.disabled();
81+
7782
/** Whether the menu is visible. */
7883
isVisible = computed(() => (this.inputs.parent() ? !!this.inputs.parent()?.expanded() : true));
7984

@@ -92,6 +97,9 @@ export class MenuPattern<V> {
9297
/** Timeout used to close sub-menus on hover out. */
9398
_closeTimeout: any;
9499

100+
/** The tab index of the menu. */
101+
tabIndex = () => this.listBehavior.tabIndex();
102+
95103
/** Whether the menu should be focused on mouse over. */
96104
shouldFocus = computed(() => {
97105
const root = this.root();
@@ -166,14 +174,13 @@ export class MenuPattern<V> {
166174
this.listBehavior = new List<MenuItemPattern<V>, V>({
167175
...inputs,
168176
values: signal([]),
169-
disabled: () => false,
170177
});
171178
}
172179

173180
/** Sets the default state for the menu. */
174181
setDefaultState() {
175182
if (!this.inputs.parent()) {
176-
this.inputs.activeItem.set(this.inputs.items()[0]);
183+
this.listBehavior.goto(this.inputs.items()[0], {focusElement: false});
177184
}
178185
}
179186

@@ -225,7 +232,7 @@ export class MenuPattern<V> {
225232
this._closeTimeout = setTimeout(() => {
226233
item.close();
227234
this._closeTimeout = undefined;
228-
}, this.inputs.expansionDelay());
235+
}, this.inputs.expansionDelay() * 1000);
229236
}
230237
}
231238

@@ -236,7 +243,7 @@ export class MenuPattern<V> {
236243
this._openTimeout = setTimeout(() => {
237244
item.open();
238245
this._openTimeout = undefined;
239-
}, this.inputs.expansionDelay());
246+
}, this.inputs.expansionDelay() * 1000);
240247
}
241248

242249
/** Handles mouseout events for the menu. */
@@ -445,6 +452,9 @@ export class MenuBarPattern<V> {
445452
/** Controls list behavior for the menu items. */
446453
listBehavior: List<MenuItemPattern<V>, V>;
447454

455+
/** The tab index of the menu. */
456+
tabIndex = () => this.listBehavior.tabIndex();
457+
448458
/** The key used to navigate to the next item. */
449459
private _nextKey = computed(() => {
450460
return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
@@ -467,6 +477,9 @@ export class MenuBarPattern<V> {
467477
/** Whether the menubar has been focused. */
468478
hasBeenFocused = signal(false);
469479

480+
/** Whether the menubar is disabled. */
481+
disabled = () => this.inputs.disabled();
482+
470483
/** Handles keyboard events for the menu. */
471484
keydownManager = computed(() => {
472485
return new KeyboardEventManager()
@@ -482,7 +495,7 @@ export class MenuBarPattern<V> {
482495
});
483496

484497
constructor(readonly inputs: MenuBarInputs<V>) {
485-
this.listBehavior = new List<MenuItemPattern<V>, V>({...inputs, disabled: () => false});
498+
this.listBehavior = new List<MenuItemPattern<V>, V>(inputs);
486499
}
487500

488501
/** Sets the default state for the menubar. */
@@ -598,6 +611,9 @@ export class MenuTriggerPattern<V> {
598611
/** The tab index of the menu trigger. */
599612
tabIndex = computed(() => (this.expanded() && this.menu()?.inputs.activeItem() ? -1 : 0));
600613

614+
/** Whether the menu trigger is disabled. */
615+
disabled = () => this.inputs.disabled();
616+
601617
/** Handles keyboard events for the menu trigger. */
602618
keydownManager = computed(() => {
603619
return new KeyboardEventManager()
@@ -614,12 +630,16 @@ export class MenuTriggerPattern<V> {
614630

615631
/** Handles keyboard events for the menu trigger. */
616632
onKeydown(event: KeyboardEvent) {
617-
this.keydownManager().handle(event);
633+
if (!this.inputs.disabled()) {
634+
this.keydownManager().handle(event);
635+
}
618636
}
619637

620638
/** Handles click events for the menu trigger. */
621639
onClick() {
622-
this.expanded() ? this.close() : this.open({first: true});
640+
if (!this.inputs.disabled()) {
641+
this.expanded() ? this.close() : this.open({first: true});
642+
}
623643
}
624644

625645
/** Handles focusin events for the menu trigger. */
@@ -681,7 +701,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
681701
id: SignalLike<string>;
682702

683703
/** Whether the menu item is disabled. */
684-
disabled: SignalLike<boolean>;
704+
disabled = () => this.inputs.parent()?.disabled() || this.inputs.disabled();
685705

686706
/** The search term for the menu item. */
687707
searchTerm: SignalLike<string>;
@@ -731,14 +751,17 @@ export class MenuItemPattern<V> implements ListItem<V> {
731751
this.id = inputs.id;
732752
this.value = inputs.value;
733753
this.element = inputs.element;
734-
this.disabled = inputs.disabled;
735754
this.submenu = this.inputs.submenu;
736755
this.searchTerm = inputs.searchTerm;
737756
this.selectable = computed(() => !this.submenu());
738757
}
739758

740759
/** Opens the submenu. */
741760
open(opts?: {first?: boolean; last?: boolean}) {
761+
if (this.disabled()) {
762+
return;
763+
}
764+
742765
this._expanded.set(true);
743766

744767
if (opts?.first) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export {MenuBarExample} from './menu-bar/menu-bar-example';
22
export {MenuBarRTLExample} from './menu-bar-rtl/menu-bar-rtl-example';
3+
export {MenuBarDisabledExample} from './menu-bar-disabled/menu-bar-disabled-example';
34
export {MenuContextExample} from './menu-context/menu-context-example';
45
export {MenuTriggerExample} from './menu-trigger/menu-trigger-example';
6+
export {MenuTriggerDisabledExample} from './menu-trigger-disabled/menu-trigger-disabled-example';
57
export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example';
8+
export {MenuStandaloneDisabledExample} from './menu-standalone-disabled/menu-standalone-disabled-example';

0 commit comments

Comments
 (0)