Skip to content

Commit efbc5b0

Browse files
authored
Merge pull request desktop#16315 from desktop/windows-app-menu-screen-reader-fixes
Windows app menu screen reader fixes
2 parents 6ffc1af + ea73aeb commit efbc5b0

File tree

8 files changed

+65
-93
lines changed

8 files changed

+65
-93
lines changed

app/src/ui/app-menu/app-menu-bar-button.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,11 @@ export class AppMenuBarButton extends React.Component<
202202
onMouseEnter={this.onMouseEnter}
203203
onKeyDown={this.onKeyDown}
204204
tabIndex={-1}
205-
role="menuitem"
205+
buttonRole="menuitem"
206+
buttonAriaHaspopup="menu"
206207
>
207208
<MenuListItem
209+
menuItemId={`app-menu-${item.label}`}
208210
item={item}
209211
highlightAccessKey={this.props.highlightMenuAccessKey}
210212
renderAcceleratorText={false}
@@ -255,7 +257,7 @@ export class AppMenuBarButton extends React.Component<
255257
if (this.isMenuOpen) {
256258
this.props.onClose(this.props.menuItem, source)
257259
} else {
258-
this.props.onOpen(this.props.menuItem)
260+
this.props.onOpen(this.props.menuItem, true)
259261
}
260262
}
261263

@@ -274,6 +276,7 @@ export class AppMenuBarButton extends React.Component<
274276
state={menuState}
275277
enableAccessKeyNavigation={this.props.enableAccessKeyNavigation}
276278
autoHeight={true}
279+
ariaLabelledby={`app-menu-${this.props.menuItem.label}`}
277280
/>
278281
)
279282
}

app/src/ui/app-menu/app-menu.tsx

Lines changed: 4 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ interface IAppMenuProps {
4949
* @default false
5050
*/
5151
readonly autoHeight?: boolean
52+
53+
/** The id of the element that serves as the menu's accessibility label */
54+
readonly ariaLabelledby: string
5255
}
5356

5457
export interface IKeyboardCloseSource {
@@ -85,22 +88,6 @@ function menuPaneClassNameFromId(id: string) {
8588
}
8689

8790
export class AppMenu extends React.Component<IAppMenuProps, {}> {
88-
/**
89-
* The index of the menu pane that should receive focus after the
90-
* next render. Default value is -1. This field is cleared after
91-
* each successful focus operation.
92-
*/
93-
private focusPane: number = -1
94-
95-
/**
96-
* A mapping between pane index (depth) and actual MenuPane instances.
97-
* This is used in order to (indirectly) call the focus method on the
98-
* underlying List instances.
99-
*
100-
* See focusPane and ensurePaneFocus
101-
*/
102-
private paneRefs: MenuPane[] = []
103-
10491
/**
10592
* A numeric reference to a setTimeout timer id which is used for
10693
* opening and closing submenus after a delay.
@@ -109,29 +96,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
10996
*/
11097
private expandCollapseTimer: number | null = null
11198

112-
public constructor(props: IAppMenuProps) {
113-
super(props)
114-
this.focusPane = props.state.length - 1
115-
this.receiveProps(null, props)
116-
}
117-
118-
private receiveProps(
119-
currentProps: IAppMenuProps | null,
120-
nextProps: IAppMenuProps
121-
) {
122-
if (nextProps.openedWithAccessKey) {
123-
// We only want to react to the openedWithAccessKey prop once, either
124-
// when it goes from false to true or when we receive it as our first
125-
// prop. By doing it this way we save ourselves having to go through
126-
// the dispatcher and updating the value once we've received it.
127-
if (!currentProps || !currentProps.openedWithAccessKey) {
128-
// Since we were opened with an access key we auto set focus to the
129-
// last pane opened.
130-
this.focusPane = nextProps.state.length - 1
131-
}
132-
}
133-
}
134-
13599
private onItemClicked = (
136100
depth: number,
137101
item: MenuItem,
@@ -154,9 +118,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
154118
this.props.dispatcher.setAppMenuState(menu =>
155119
menu.withOpenedMenu(item, sourceIsAccessKey)
156120
)
157-
if (source.kind === 'keyboard') {
158-
this.focusPane = depth + 1
159-
}
160121
} else if (item.type !== 'separator') {
161122
// Send the close event before actually executing the item so that
162123
// the menu can restore focus to the previously selected element
@@ -185,7 +146,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
185146
menu.withClosedMenu(this.props.state[depth])
186147
)
187148

188-
this.focusPane = depth - 1
189149
event.preventDefault()
190150
}
191151
} else if (event.key === 'ArrowRight') {
@@ -196,7 +156,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
196156
this.props.dispatcher.setAppMenuState(menu =>
197157
menu.withOpenedMenu(selectedItem, true)
198158
)
199-
this.focusPane = depth + 1
200159
event.preventDefault()
201160
}
202161
}
@@ -255,12 +214,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
255214
}
256215
}
257216

258-
private onMenuPaneRef = (pane: MenuPane | null) => {
259-
if (pane) {
260-
this.paneRefs[pane.props.depth] = pane
261-
}
262-
}
263-
264217
private onPaneMouseEnter = (depth: number) => {
265218
this.clearExpandCollapseTimer()
266219

@@ -275,8 +228,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
275228
// This ensures that the selection to this menu is reset.
276229
this.props.dispatcher.setAppMenuState(m => m.withDeselectedMenu(paneMenu))
277230
}
278-
279-
this.focusPane = depth
280231
}
281232

282233
private onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
@@ -294,7 +245,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
294245
return (
295246
<MenuPane
296247
key={key}
297-
ref={this.onMenuPaneRef}
298248
className={className}
299249
depth={depth}
300250
items={menu.items}
@@ -305,6 +255,7 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
305255
onSelectionChanged={this.onSelectionChanged}
306256
enableAccessKeyNavigation={this.props.enableAccessKeyNavigation}
307257
onClearSelection={this.onClearSelection}
258+
ariaLabelledby={this.props.ariaLabelledby}
308259
/>
309260
)
310261
}
@@ -313,10 +264,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
313264
const menus = this.props.state
314265
const panes = menus.map((m, depth) => this.renderMenuPane(depth, m))
315266

316-
// Clear out any old references we might have to panes that are
317-
// no longer displayed.
318-
this.paneRefs = this.paneRefs.slice(0, panes.length)
319-
320267
return (
321268
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
322269
<div id="app-menu-foldout" onKeyDown={this.onKeyDown}>
@@ -325,32 +272,6 @@ export class AppMenu extends React.Component<IAppMenuProps, {}> {
325272
)
326273
}
327274

328-
/**
329-
* Called after mounting or re-rendering and ensures that the
330-
* appropriate (if any) MenuPane list receives keyboard focus.
331-
*/
332-
private ensurePaneFocus() {
333-
if (this.focusPane >= 0) {
334-
const pane = this.paneRefs[this.focusPane]
335-
if (pane) {
336-
pane.focus()
337-
this.focusPane = -1
338-
}
339-
}
340-
}
341-
342-
public componentWillReceiveProps(nextProps: IAppMenuProps) {
343-
this.receiveProps(this.props, nextProps)
344-
}
345-
346-
public componentDidMount() {
347-
this.ensurePaneFocus()
348-
}
349-
350-
public componentDidUpdate() {
351-
this.ensurePaneFocus()
352-
}
353-
354275
public componentWillUnmount() {
355276
this.clearExpandCollapseTimer()
356277
}

app/src/ui/app-menu/menu-list-item.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { getPlatformSpecificNameOrSymbolForModifier } from '../../lib/menu-item'
1010
interface IMenuListItemProps {
1111
readonly item: MenuItem
1212

13+
/**
14+
* A unique identifier for the menu item. On use is to link it to the menu
15+
* for accessibility labelling.
16+
*/
17+
readonly menuItemId?: string
18+
1319
/**
1420
* Whether or not to highlight the access key of a menu item (if one exists).
1521
*
@@ -150,14 +156,16 @@ export class MenuListItem extends React.Component<IMenuListItemProps, {}> {
150156
})
151157

152158
return (
153-
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
159+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
154160
<div
161+
id={this.props.menuItemId}
155162
className={className}
156163
onMouseEnter={this.onMouseEnter}
157164
onMouseLeave={this.onMouseLeave}
158165
onClick={this.onClick}
159166
ref={this.wrapperRef}
160167
role="menuitem"
168+
tabIndex={-1}
161169
>
162170
{this.getIcon(item)}
163171
<div className="label">

app/src/ui/app-menu/menu-pane.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,12 @@ interface IMenuPaneProps {
8989
* will be called when the user's pointer device leaves a menu item.
9090
*/
9191
readonly onClearSelection: (depth: number) => void
92+
93+
/** The id of the element that serves as the menu's accessibility label */
94+
readonly ariaLabelledby: string
9295
}
9396

9497
export class MenuPane extends React.Component<IMenuPaneProps> {
95-
private paneRef = React.createRef<HTMLDivElement>()
96-
9798
private onRowClick = (
9899
item: MenuItem,
99100
event: React.MouseEvent<HTMLDivElement>
@@ -215,9 +216,9 @@ export class MenuPane extends React.Component<IMenuPaneProps> {
215216
className={className}
216217
onMouseEnter={this.onMouseEnter}
217218
onKeyDown={this.onKeyDown}
218-
ref={this.paneRef}
219219
tabIndex={-1}
220220
role="menu"
221+
aria-labelledby={this.props.ariaLabelledby}
221222
>
222223
{this.props.items
223224
.filter(x => x.visible)
@@ -236,10 +237,6 @@ export class MenuPane extends React.Component<IMenuPaneProps> {
236237
</div>
237238
)
238239
}
239-
240-
public focus() {
241-
this.paneRef.current?.focus()
242-
}
243240
}
244241

245242
const supportedKeys = [

app/src/ui/lib/aria-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { HTMLAttributes } from 'react'
2+
3+
export type AriaHasPopupType = HTMLAttributes<HTMLElement>['aria-haspopup']

app/src/ui/lib/button.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react'
22
import classNames from 'classnames'
33
import { Tooltip, TooltipDirection } from './tooltip'
44
import { createObservableRef } from './observable-ref'
5+
import { AriaHasPopupType } from './aria-types'
56

67
export interface IButtonProps {
78
/**
@@ -74,9 +75,41 @@ export interface IButtonProps {
7475
*/
7576
readonly tabIndex?: number
7677

78+
/**
79+
* ARIA roles provide semantic meaning to content, allowing screen readers and
80+
* other tools to present and support interaction with object in a way that is
81+
* consistent with user expectations of that type of object. ARIA roles can be
82+
* used to describe elements that don't natively exist in HTML or exist but
83+
* don't yet have full browser support.
84+
*
85+
* By default, many semantic elements in HTML have a role; for example, <input
86+
* type="radio"> has the "radio" role. Non-semantic elements in HTML do not
87+
* have a role; <div> and <span> without added semantics return null. The role
88+
* attribute can provide semantics.
89+
*/
7790
readonly role?: string
91+
92+
/**
93+
* The aria-expanded attribute is set on an element to indicate if a control
94+
* is expanded or collapsed, and whether or not the controlled elements are
95+
* displayed or hidden.
96+
*
97+
* There are several widgets that can be expanded and collapsed, including
98+
* menus, dialogs, and accordion panels. Each of these objects, in turn, has
99+
* an interactive element that controls their opening and closing. The
100+
* aria-expanded attribute is applied to this focusable, interactive control
101+
* that toggles the visibility of the object.
102+
*/
78103
readonly ariaExpanded?: boolean
79104

105+
/** An aria attribute indicates the availability and type of interactive popup
106+
* element that can be triggered by the element on which the attribute is set
107+
*
108+
* Notes: The value true is the same as menu. Any other value, including an
109+
* empty string or other role, is treated as if false were set.
110+
* */
111+
readonly ariaHaspopup?: AriaHasPopupType
112+
80113
/**
81114
* Typically the contents of a button serve the purpose of describing the
82115
* buttons use. However, ariaLabel can be used if the contents do not suffice.
@@ -142,6 +175,7 @@ export class Button extends React.Component<IButtonProps, {}> {
142175
aria-expanded={this.props.ariaExpanded}
143176
aria-disabled={disabled ? 'true' : undefined}
144177
aria-label={this.props.ariaLabel}
178+
aria-haspopup={this.props.ariaHaspopup}
145179
>
146180
{tooltip && (
147181
<Tooltip

app/src/ui/toolbar/button.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '../lib/button'
77
import { clamp } from '../../lib/clamp'
88
import { createObservableRef } from '../lib/observable-ref'
99
import { Tooltip, TooltipDirection, TooltipTarget } from '../lib/tooltip'
10+
import { AriaHasPopupType } from '../lib/aria-types'
1011

1112
/** The button style. */
1213
export enum ToolbarButtonStyle {
@@ -108,6 +109,7 @@ export interface IToolbarButtonProps {
108109

109110
readonly role?: string
110111
readonly ariaExpanded?: boolean
112+
readonly ariaHaspopup?: AriaHasPopupType
111113

112114
/**
113115
* Whether to only show the tooltip when the tooltip target overflows its
@@ -221,6 +223,7 @@ export class ToolbarButton extends React.Component<IToolbarButtonProps, {}> {
221223
tabIndex={this.props.tabIndex}
222224
role={this.props.role}
223225
ariaExpanded={this.props.ariaExpanded}
226+
ariaHaspopup={this.props.ariaHaspopup}
224227
>
225228
{progress}
226229
{icon}

app/src/ui/toolbar/dropdown.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import classNames from 'classnames'
1010
import FocusTrap from 'focus-trap-react'
1111
import { Options as FocusTrapOptions } from 'focus-trap'
1212
import { TooltipTarget } from '../lib/tooltip'
13+
import { AriaHasPopupType } from '../lib/aria-types'
1314

1415
export type DropdownState = 'open' | 'closed'
1516

@@ -168,6 +169,7 @@ export interface IToolbarDropdownProps {
168169

169170
readonly role?: string
170171
readonly buttonRole?: string
172+
readonly buttonAriaHaspopup?: AriaHasPopupType
171173

172174
/** Classes to be appended to `ToolbarButton` component */
173175
readonly buttonClassName?: string
@@ -448,6 +450,7 @@ export class ToolbarDropdown extends React.Component<
448450
this.props.onlyShowTooltipWhenOverflowed
449451
}
450452
isOverflowed={this.props.isOverflowed}
453+
ariaHaspopup={this.props.buttonAriaHaspopup}
451454
>
452455
{this.props.children}
453456
{this.props.dropdownStyle !== ToolbarDropdownStyle.MultiOption &&

0 commit comments

Comments
 (0)