Skip to content

Commit 92cdc42

Browse files
authored
Fix/dropdown accessibility (react-bootstrap#4977) (react-bootstrap#4978)
* Allow id and aria-controls props to pass through AbstractNavItem if navContext doesn't provide a value. * Fix documentation typo * Add aria-controls to NavDropdown and always render the dropdown menu. * Add tests for the dropdown id and aria-controls changes * Add aria-controls to DropdownToggle in DropdownButton and SplitButton * Add tests for aria-controls attribute on DropdownButton and SplitButton * Fix test name typo * Add tests for aria-controls attribute on DropdownButton and SplitButton * Add prop to render dropdown menus in the DOM before being shown * Display a warning if user's id is ignored in AbstractNavItem. Also pass the user provided id through to Tab children so the inner AbstractNavItem knows the user's id is being overwritten.
1 parent 0a50fa3 commit 92cdc42

File tree

13 files changed

+76
-8
lines changed

13 files changed

+76
-8
lines changed

src/AbstractNavItem.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import PropTypes from 'prop-types';
33
import React, { useContext } from 'react';
44
import useEventCallback from '@restart/hooks/useEventCallback';
55

6+
import warning from 'warning';
67
import NavContext from './NavContext';
78
import SelectableContext, { makeEventKey } from './SelectableContext';
89

910
const propTypes = {
11+
id: PropTypes.string,
1012
active: PropTypes.bool,
1113
role: PropTypes.string,
1214

@@ -18,6 +20,8 @@ const propTypes = {
1820
as: PropTypes.any,
1921
onClick: PropTypes.func,
2022
onSelect: PropTypes.func,
23+
24+
'aria-controls': PropTypes.string,
2125
};
2226

2327
const defaultProps = {
@@ -46,9 +50,21 @@ const AbstractNavItem = React.forwardRef(
4650
if (navContext) {
4751
if (!props.role && navContext.role === 'tablist') props.role = 'tab';
4852

53+
const contextControllerId = navContext.getControllerId(navKey);
54+
const contextControlledId = navContext.getControlledId(navKey);
55+
56+
warning(
57+
!contextControllerId || !props.id,
58+
`[react-bootstrap] The provided id '${props.id}' was overwritten by the current navContext with '${contextControllerId}'.`,
59+
);
60+
warning(
61+
!contextControlledId || !props['aria-controls'],
62+
`[react-bootstrap] The provided aria-controls value '${props['aria-controls']}' was overwritten by the current navContext with '${contextControlledId}'.`,
63+
);
64+
4965
props['data-rb-event-key'] = navKey;
50-
props.id = navContext.getControllerId(navKey);
51-
props['aria-controls'] = navContext.getControlledId(navKey);
66+
props.id = contextControllerId || props.id;
67+
props['aria-controls'] = contextControlledId || props['aria-controls'];
5268

5369
isActive =
5470
active == null && navKey != null

src/DropdownButton.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const propTypes = {
2626
/** An ARIA accessible role applied to the Menu component. When set to 'menu', The dropdown */
2727
menuRole: PropTypes.string,
2828

29+
/** Whether to render the dropdown menu in the DOM before the first time it is shown */
30+
renderMenuOnMount: PropTypes.bool,
31+
2932
/**
3033
* Which event when fired outside the component will cause it to be closed.
3134
*
@@ -59,6 +62,7 @@ const DropdownButton = React.forwardRef(
5962
variant,
6063
size,
6164
menuRole,
65+
renderMenuOnMount,
6266
disabled,
6367
href,
6468
id,
@@ -77,7 +81,11 @@ const DropdownButton = React.forwardRef(
7781
>
7882
{title}
7983
</Dropdown.Toggle>
80-
<Dropdown.Menu role={menuRole} rootCloseEvent={rootCloseEvent}>
84+
<Dropdown.Menu
85+
role={menuRole}
86+
renderOnMount={renderMenuOnMount}
87+
rootCloseEvent={rootCloseEvent}
88+
>
8189
{children}
8290
</Dropdown.Menu>
8391
</Dropdown>

src/DropdownMenu.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const propTypes = {
1616
/** Controls the visibility of the Dropdown menu */
1717
show: PropTypes.bool,
1818

19+
/** Whether to render the dropdown menu in the DOM before the first time it is shown */
20+
renderOnMount: PropTypes.bool,
21+
1922
/** Have the dropdown switch to it's opposite placement when necessary to stay on screen. */
2023
flip: PropTypes.bool,
2124

@@ -63,6 +66,7 @@ const DropdownMenu = React.forwardRef(
6366
flip,
6467
popperConfig,
6568
show: showProps,
69+
renderOnMount,
6670
// Need to define the default "as" during prop destructuring to be compatible with styled-components github.com/react-bootstrap/react-bootstrap/issues/3595
6771
as: Component = 'div',
6872
...props
@@ -92,7 +96,7 @@ const DropdownMenu = React.forwardRef(
9296
useWrappedRefWithWarning(ref, 'DropdownMenu'),
9397
);
9498

95-
if (!hasShown) return null;
99+
if (!hasShown && !renderOnMount) return null;
96100

97101
// For custom components provide additional, non-DOM, props;
98102
if (typeof Component !== 'string') {

src/NavDropdown.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const propTypes = {
2828
/** An ARIA accessible role applied to the Menu component. When set to 'menu', The dropdown */
2929
menuRole: PropTypes.string,
3030

31+
/** Whether to render the dropdown menu in the DOM before the first time it is shown */
32+
renderMenuOnMount: PropTypes.bool,
33+
3134
/**
3235
* Which event when fired outside the component will cause it to be closed.
3336
*
@@ -50,6 +53,7 @@ const NavDropdown = React.forwardRef(
5053
menuRole,
5154
disabled,
5255
active,
56+
renderMenuOnMount,
5357
...props
5458
},
5559
ref,
@@ -66,7 +70,11 @@ const NavDropdown = React.forwardRef(
6670
{title}
6771
</Dropdown.Toggle>
6872

69-
<Dropdown.Menu role={menuRole} rootCloseEvent={rootCloseEvent}>
73+
<Dropdown.Menu
74+
role={menuRole}
75+
renderOnMount={renderMenuOnMount}
76+
rootCloseEvent={rootCloseEvent}
77+
>
7078
{children}
7179
</Dropdown.Menu>
7280
</Dropdown>

src/SplitButton.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ const propTypes = {
3838

3939
/** An ARIA accessible role applied to the Menu component. When set to 'menu', The dropdown */
4040
menuRole: PropTypes.string,
41+
42+
/** Whether to render the dropdown menu in the DOM before the first time it is shown */
43+
renderMenuOnMount: PropTypes.bool,
44+
4145
/**
4246
* Which event when fired outside the component will cause it to be closed.
4347
*
@@ -73,6 +77,7 @@ const SplitButton = React.forwardRef(
7377
href,
7478
target,
7579
menuRole,
80+
renderMenuOnMount,
7681
rootCloseEvent,
7782
...props
7883
},
@@ -102,7 +107,11 @@ const SplitButton = React.forwardRef(
102107
<span className="sr-only">{toggleLabel}</span>
103108
</Dropdown.Toggle>
104109

105-
<Dropdown.Menu role={menuRole} rootCloseEvent={rootCloseEvent}>
110+
<Dropdown.Menu
111+
role={menuRole}
112+
renderOnMount={renderMenuOnMount}
113+
rootCloseEvent={rootCloseEvent}
114+
>
106115
{children}
107116
</Dropdown.Menu>
108117
</Dropdown>

src/Tabs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function getDefaultActiveKey(children) {
9494
}
9595

9696
function renderTab(child) {
97-
const { title, eventKey, disabled, tabClassName } = child.props;
97+
const { title, eventKey, disabled, tabClassName, id } = child.props;
9898
if (title == null) {
9999
return null;
100100
}
@@ -104,6 +104,7 @@ function renderTab(child) {
104104
as={NavLink}
105105
eventKey={eventKey}
106106
disabled={disabled}
107+
id={id}
107108
className={tabClassName}
108109
>
109110
{title}

test/DropdownMenuSpec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ describe('<Dropdown.Menu>', () => {
3333
).assertSingle('.dropdown-menu-right');
3434
});
3535

36+
it('renders on mount with prop', () => {
37+
mount(
38+
<DropdownMenu renderOnMount>
39+
<DropdownItem>Item</DropdownItem>
40+
</DropdownMenu>,
41+
).assertSingle('div.dropdown-menu');
42+
});
43+
3644
// it.only('warns about bad refs', () => {
3745
// class Parent extends React.Component {
3846
// componentDidCatch() {}

test/NavDropdownSpec.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,14 @@ describe('<NavDropdown>', () => {
4949
.text()
5050
.should.equal('DropdownItem 2 content');
5151
});
52+
53+
it('should pass the id to the NavLink element', () => {
54+
const wrapper = mount(
55+
<NavDropdown id="test-id" title="title">
56+
<DropdownItem eventKey="1">DropdownItem 1 content</DropdownItem>
57+
</NavDropdown>,
58+
);
59+
60+
wrapper.assertSingle('a#test-id');
61+
});
5262
});

types/components/DropdownButton.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface DropdownButtonProps extends PropsFromToggle {
1616
id: string;
1717
title: React.ReactNode;
1818
menuRole?: string;
19+
renderMenuOnMount?: boolean;
1920
rootCloseEvent?: 'click' | 'mousedown';
2021
bsPrefix?: string;
2122
}

types/components/DropdownMenu.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BsPrefixComponent, SelectCallback } from './helpers';
44

55
export interface DropdownMenuProps {
66
show?: boolean;
7+
renderOnMount?: boolean;
78
flip?: boolean;
89
alignRight?: boolean;
910
onSelect?: SelectCallback;

types/components/NavDropdown.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface NavDropdownProps {
1010
disabled?: boolean;
1111
active?: boolean;
1212
menuRole?: string;
13+
renderMenuOnMount?: boolean;
1314
rootCloseEvent?: 'click' | 'mousedown';
1415
bsPrefix?: string;
1516
}

types/components/SplitButton.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface SplitButtonProps extends PropsFromToggle {
2020
onClick?: React.MouseEventHandler<this>;
2121
title: React.ReactNode;
2222
menuRole?: string;
23+
renderMenuOnMount?: boolean;
2324
rootCloseEvent?: 'click' | 'mousedown';
2425
bsPrefix?: string;
2526
}

www/src/pages/components/navs.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ component
116116

117117
<CodeBlock codeText={NavDropdownImpl} />
118118

119-
The above demostrates how flexible the component model can be. But if
119+
The above demonstrates how flexible the component model can be. But if
120120
you didn't want to roll your own versions we've included a
121121
straight-forward `<NavDropdown>` that works for most cases.
122122

0 commit comments

Comments
 (0)