Skip to content
This repository was archived by the owner on May 20, 2023. It is now read-only.

Commit 091961b

Browse files
Googlernshahan
authored andcommitted
Add keyboard accessibility functionality for active item handling to material-menu.
Pressing space/enter/down on the trigger button opens the menu with the first item selected. Pressing up on the trigger button opens the menu with the last item selected. PiperOrigin-RevId: 243099147
1 parent 17dad7e commit 091961b

File tree

6 files changed

+155
-31
lines changed

6 files changed

+155
-31
lines changed

angular_components/lib/material_menu/dropdown_menu.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'package:angular/angular.dart';
6-
import 'package:angular_components/interfaces/has_disabled.dart';
76
import 'package:angular_components/focus/focus.dart';
7+
import 'package:angular_components/interfaces/has_disabled.dart';
88
import 'package:angular_components/material_menu/menu_popup.dart';
99
import 'package:angular_components/material_menu/menu_popup_wrapper.dart';
1010
import 'package:angular_components/material_popup/material_popup.dart';
1111
import 'package:angular_components/material_select/dropdown_button.dart';
1212
import 'package:angular_components/mixins/focusable_mixin.dart';
13+
import 'package:angular_components/model/a11y/keyboard_handler_mixin.dart';
1314
import 'package:angular_components/utils/disposer/disposer.dart';
1415

1516
/// The [DropdownMenuComponent] combines a [DropdownButtonComponent] with a
@@ -28,7 +29,11 @@ import 'package:angular_components/utils/disposer/disposer.dart';
2829
preserveWhitespace: true,
2930
)
3031
class DropdownMenuComponent extends Object
31-
with FocusableMixin, MenuPopupWrapper
32+
with
33+
FocusableMixin,
34+
KeyboardHandlerMixin,
35+
MenuPopupWrapper,
36+
MenuPopupTrigger
3237
implements AfterViewInit, HasDisabled, OnDestroy {
3338
final _disposer = Disposer.oneShot();
3439

angular_components/lib/material_menu/dropdown_menu.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
-->
66
<dropdown-button
77
[ariaDescribedBy]="buttonAriaDescribedBy"
8-
(trigger)="isExpanded=true"
8+
(keydown)="onKeyDown($event)"
9+
(trigger)="handlePopupTriggerAction"
910
[buttonText]="buttonText"
1011
[disabled]="disabled"
1112
[tabbable]="tabbable"
@@ -14,7 +15,7 @@
1415
<ng-content select="[button-content]"></ng-content>
1516
</dropdown-button>
1617
<menu-popup
17-
[(isExpanded)]="isExpanded"
18+
[(expandAction)]="expandAction"
1819
[menu]="menu"
1920
[popupSource]="toggle"
2021
[preferredPositions]="preferredPositions"

angular_components/lib/material_menu/material_menu.dart

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5-
import 'dart:async';
65
import 'dart:html';
76

87
import 'package:angular/angular.dart';
@@ -15,6 +14,7 @@ import 'package:angular_components/material_menu/menu_popup_wrapper.dart';
1514
import 'package:angular_components/material_popup/material_popup.dart';
1615
import 'package:angular_components/material_tooltip/material_tooltip.dart';
1716
import 'package:angular_components/mixins/focusable_mixin.dart';
17+
import 'package:angular_components/model/a11y/keyboard_handler_mixin.dart';
1818
import 'package:angular_components/model/menu/menu.dart';
1919
import 'package:angular_components/utils/angular/css/css.dart';
2020
import 'package:angular_components/utils/disposer/disposer.dart';
@@ -39,10 +39,13 @@ import 'package:angular_components/utils/disposer/disposer.dart';
3939
templateUrl: 'material_menu.html',
4040
changeDetection: ChangeDetectionStrategy.OnPush)
4141
class MaterialMenuComponent extends Object
42-
with FocusableMixin, MenuPopupWrapper
42+
with
43+
FocusableMixin,
44+
KeyboardHandlerMixin,
45+
MenuPopupWrapper,
46+
MenuPopupTrigger
4347
implements AfterViewInit, HasDisabled, OnDestroy {
4448
final HtmlElement _root;
45-
final _onTrigger = StreamController<void>();
4649
final _disposer = Disposer.oneShot();
4750

4851
MaterialMenuComponent(this._root);
@@ -63,14 +66,6 @@ class MaterialMenuComponent extends Object
6366
@Input()
6467
String buttonText;
6568

66-
/// If true, the material menu will be closed if the trigger button is clicked
67-
/// while the menu is open.
68-
///
69-
/// Otherwise, clicking the trigger button when the menu is already open will
70-
/// not do anything.
71-
@Input()
72-
bool closeMenuOnClick = false;
73-
7469
/// Whether the menu is disabled or not.
7570
@Input()
7671
bool disabled = false;
@@ -83,10 +78,6 @@ class MaterialMenuComponent extends Object
8378
@Input()
8479
String ariaLabel;
8580

86-
/// Outputs an event when the menu button is triggered.
87-
@Output('trigger')
88-
Stream<void> get onTrigger => _onTrigger.stream;
89-
9081
String get tooltipText => menu?.tooltipText;
9182

9283
bool get hasTooltip => menu?.hasTooltip ?? false;
@@ -95,11 +86,6 @@ class MaterialMenuComponent extends Object
9586

9687
String get hasIcon => menu?.uiIcon != null ? 'true' : null;
9788

98-
void handleButtonClick() {
99-
isExpanded = closeMenuOnClick ? !isExpanded : true;
100-
_onTrigger.add(null);
101-
}
102-
10389
MaterialButtonComponent _button;
10490

10591
@ViewChild(MaterialButtonComponent)

angular_components/lib/material_menu/material_menu.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
BSD-style license that can be found in the LICENSE file.
55
-->
66
<material-button
7-
(trigger)="handleButtonClick"
7+
(keydown)="onKeyDown($event)"
8+
(trigger)="handlePopupTriggerAction"
89
[attr.aria-label]="ariaLabel"
910
[attr.icon]="hasIcon"
1011
[disabled]="disabled"
@@ -23,7 +24,7 @@
2324
</material-button>
2425
<menu-popup
2526
*ngIf="hasSubmenu"
26-
[(isExpanded)]="isExpanded"
27+
[(expandAction)]="expandAction"
2728
[popupClass]="popupClass"
2829
[menu]="menu"
2930
[popupSource]="toggle"

angular_components/lib/material_menu/menu_popup.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
role="none"
1616
[width]="width">
1717
<menu-item-groups
18+
[activateFirstItemOnInit]="activateFirstItemOnExpand"
19+
[activateLastItemOnInit]="activateLastItemOnExpand"
1820
[menu]="menu"
1921
[popupClass]="popupClass"
2022
menu-root

angular_components/lib/material_menu/menu_popup_wrapper.dart

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,92 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'dart:async';
6+
import 'dart:html';
67

78
import 'package:angular/angular.dart';
89
import 'package:angular_components/model/menu/menu.dart';
910
import 'package:angular_components/model/observable/observable.dart';
1011
import 'package:angular_components/model/ui/accepts_width.dart';
1112
import 'package:angular_components/utils/angular/properties/properties.dart';
1213

14+
/// Represents an intent to open the menu popup.
15+
///
16+
/// This data struct is used to pass parameters along with the intent, such as
17+
/// which menu item to focus when the item is first opened.
18+
class ExpandAction {
19+
final int _focusIndexOnExpand;
20+
21+
const ExpandAction.withFirstItemFocused() : _focusIndexOnExpand = 0;
22+
23+
const ExpandAction.withLastItemFocused() : _focusIndexOnExpand = -1;
24+
25+
const ExpandAction.withNoFocus() : _focusIndexOnExpand = null;
26+
27+
bool get activateFirstItemOnExpand => _focusIndexOnExpand == 0;
28+
bool get activateLastItemOnExpand => _focusIndexOnExpand == -1;
29+
}
30+
1331
/// A mixin for classes that wrap a [MenuPopupComponent].
1432
class MenuPopupWrapper implements AcceptsWidth {
33+
final _expandAction = ObservableReference<ExpandAction>(null);
34+
1535
/// The displayed menu.
1636
@Input()
1737
MenuModel menu;
1838

1939
/// Whether the menu is open.
40+
///
41+
/// Sets the default expansion state when this input is used to control menu
42+
/// visibility. Default state means no menu items are focused when the menu
43+
/// popup is opened.
2044
@Input()
2145
set isExpanded(value) {
22-
if (_expanded.value == value) return;
23-
_expanded.value = getBool(value);
46+
if (isExpanded == value) return;
47+
48+
if (getBool(value)) {
49+
expandAction ??= const ExpandAction.withNoFocus();
50+
} else {
51+
expandAction = null;
52+
}
2453
}
2554

26-
bool get isExpanded => _expanded.value;
55+
/// Expansion state of the menu popup.
56+
///
57+
/// Null value means popup is closed, any other value means popup is open.
58+
/// This input is used to pass parameters with the open action. E.g. which
59+
/// element is selected when the popup is focused.
60+
@Input()
61+
set expandAction(ExpandAction value) {
62+
if (_expandAction.value == value) return;
63+
64+
_expandAction.value = value;
65+
}
66+
67+
/// True if the menu popup is expanded/open.
68+
bool get isExpanded => expandAction != null;
69+
70+
ExpandAction get expandAction => _expandAction.value;
71+
72+
/// True if the menu item group should select the first item when the popup
73+
/// is opened.
74+
bool get activateFirstItemOnExpand =>
75+
expandAction?.activateFirstItemOnExpand ?? false;
76+
77+
/// True if the menu item group should select the first item when the popup
78+
/// is opened.
79+
bool get activateLastItemOnExpand =>
80+
expandAction?.activateLastItemOnExpand ?? false;
2781

2882
/// Outputs an event when the menu is expanded.
2983
@Output()
30-
Stream<bool> get isExpandedChange => _expanded.stream;
31-
final _expanded = ObservableReference<bool>(false);
84+
Stream<bool> get isExpandedChange =>
85+
_expandAction.stream.map((_) => isExpanded);
86+
87+
/// Outputs an event when the menu expansion state is changed.
88+
///
89+
///
90+
@Output()
91+
Stream<ExpandAction> get expandActionChange => _expandAction.stream;
3292

3393
/// Selects 1 of 5 predefined width values for the menu.
3494
///
@@ -52,3 +112,72 @@ class MenuPopupWrapper implements AcceptsWidth {
52112
@Input()
53113
Iterable preferredPositions;
54114
}
115+
116+
/// Provides basic accessibility-friendly methods for showing and hiding the
117+
/// menu popup described by [MenuPopupWrapper].
118+
///
119+
/// Basic usage:
120+
/// <div
121+
/// buttonDecorator
122+
/// (keydown)="onKeyDown($event)"
123+
/// (trigger)="handlePopupTriggerAction">
124+
/// button
125+
/// </div>
126+
///
127+
/// Should be a mixin, but cannot because of ACX gallery compilation limitation
128+
/// See b/130170577 for details.
129+
abstract class MenuPopupTrigger {
130+
final _onTriggerAction = StreamController<void>();
131+
132+
/// If true, the menu popup will be closed if the trigger button is clicked
133+
/// while the menu is open.
134+
///
135+
/// Otherwise, clicking the trigger button when the menu is already open will
136+
/// not do anything.
137+
@Input()
138+
bool closeMenuOnClick = false;
139+
140+
/// Outputs an event when the menu button is triggered.
141+
@Output('trigger')
142+
Stream<void> get onTrigger => _onTriggerAction.stream;
143+
144+
bool get isExpanded;
145+
146+
set expandAction(ExpandAction value);
147+
148+
// The following methods are for accessibility-friendly trigger actions.
149+
// See the 'Keyboard Interaction' section on this page:
150+
// https://www.w3.org/TR/wai-aria-practices/#menubutton
151+
void handlePopupTriggerAction(UIEvent event) {
152+
if (event is KeyboardEvent) {
153+
_trigger(const ExpandAction.withFirstItemFocused());
154+
} else {
155+
_trigger(const ExpandAction.withNoFocus());
156+
}
157+
}
158+
159+
/// Provides the default implementation if parent class mixes
160+
/// in [KeyboardHandlerMixin].
161+
void handleUpKey(KeyboardEvent event) {
162+
_trigger(const ExpandAction.withLastItemFocused());
163+
// Prevent the scrolling associated with arrow keys.
164+
event.preventDefault();
165+
}
166+
167+
/// Provides the default implementation if parent class mixes
168+
/// in [KeyboardHandlerMixin].
169+
void handleDownKey(KeyboardEvent event) {
170+
_trigger(const ExpandAction.withFirstItemFocused());
171+
// Prevent the scrolling associated with arrow keys.
172+
event.preventDefault();
173+
}
174+
175+
void _trigger(ExpandAction action) {
176+
if (closeMenuOnClick && isExpanded) {
177+
expandAction = null;
178+
} else {
179+
expandAction = action;
180+
}
181+
_onTriggerAction.add(null);
182+
}
183+
}

0 commit comments

Comments
 (0)