@dhis2-ui/menu 9.9.0-alpha.1 → 9.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cjs/menu/__tests__/menu.test.js +234 -0
- package/build/cjs/menu/helpers.js +39 -0
- package/build/cjs/menu/menu.js +51 -7
- package/build/cjs/menu/use-menu.js +96 -0
- package/build/cjs/menu-divider/menu-divider.js +1 -0
- package/build/cjs/menu-item/__tests__/menu-item.test.js +74 -0
- package/build/cjs/menu-item/menu-item.js +16 -2
- package/build/cjs/menu-item/menu-item.stories.js +3 -1
- package/build/es/menu/__tests__/menu.test.js +196 -0
- package/build/es/menu/helpers.js +27 -0
- package/build/es/menu/menu.js +47 -7
- package/build/es/menu/use-menu.js +85 -0
- package/build/es/menu-divider/menu-divider.js +1 -0
- package/build/es/menu-item/__tests__/menu-item.test.js +67 -0
- package/build/es/menu-item/menu-item.js +16 -2
- package/build/es/menu-item/menu-item.stories.js +3 -1
- package/package.json +8 -8
- package/types/index.d.ts +13 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _react = require("@testing-library/react");
|
|
4
|
+
|
|
5
|
+
var _userEvent = _interopRequireDefault(require("@testing-library/user-event"));
|
|
6
|
+
|
|
7
|
+
var _enzyme = require("enzyme");
|
|
8
|
+
|
|
9
|
+
var _react2 = _interopRequireDefault(require("react"));
|
|
10
|
+
|
|
11
|
+
var _menuDivider = require("../../menu-divider/menu-divider.js");
|
|
12
|
+
|
|
13
|
+
var _menuItem = require("../../menu-item/menu-item.js");
|
|
14
|
+
|
|
15
|
+
var _menuSectionHeader = require("../../menu-section-header/menu-section-header.js");
|
|
16
|
+
|
|
17
|
+
var _menu = require("../menu.js");
|
|
18
|
+
|
|
19
|
+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
20
|
+
|
|
21
|
+
describe('Menu Component', () => {
|
|
22
|
+
const menuDataTest = 'data-test-menu';
|
|
23
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
24
|
+
const dividerDataTest = 'data-test-menu-divider';
|
|
25
|
+
it('Menu and menu items have aria attributes', () => {
|
|
26
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
27
|
+
dataTest: menuDataTest,
|
|
28
|
+
dense: false
|
|
29
|
+
}, /*#__PURE__*/_react2.default.createElement(_menuSectionHeader.MenuSectionHeader, {
|
|
30
|
+
label: "Header"
|
|
31
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuDivider.MenuDivider, {
|
|
32
|
+
dataTest: dividerDataTest
|
|
33
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
34
|
+
dataTest: menuItemDataTest,
|
|
35
|
+
label: "Menu item"
|
|
36
|
+
})));
|
|
37
|
+
const menuElement = wrapper.find({
|
|
38
|
+
'data-test': menuDataTest
|
|
39
|
+
});
|
|
40
|
+
const menuItem = wrapper.find({
|
|
41
|
+
'data-test': menuItemDataTest
|
|
42
|
+
});
|
|
43
|
+
const menuDivider = wrapper.find({
|
|
44
|
+
'data-test': dividerDataTest
|
|
45
|
+
});
|
|
46
|
+
expect(menuElement.prop('role')).toBe('menu');
|
|
47
|
+
expect(menuItem.childAt(0).props().role).toBe('menuitem');
|
|
48
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Menu item');
|
|
49
|
+
expect(menuDivider.prop('role')).toBe('separator');
|
|
50
|
+
});
|
|
51
|
+
it('Empty menu has role menu', () => {
|
|
52
|
+
const menuDataTest = 'data-test-menu';
|
|
53
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
54
|
+
dataTest: menuDataTest,
|
|
55
|
+
dense: false
|
|
56
|
+
}));
|
|
57
|
+
const menuElement = wrapper.find({
|
|
58
|
+
'data-test': menuDataTest
|
|
59
|
+
});
|
|
60
|
+
expect(menuElement.prop('role')).toBe('menu');
|
|
61
|
+
});
|
|
62
|
+
it('can handle focus of first focusable element when tabbed to', () => {
|
|
63
|
+
const {
|
|
64
|
+
getByRole,
|
|
65
|
+
getByText
|
|
66
|
+
} = (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
67
|
+
dataTest: menuDataTest,
|
|
68
|
+
dense: false
|
|
69
|
+
}, /*#__PURE__*/_react2.default.createElement(_menuSectionHeader.MenuSectionHeader, {
|
|
70
|
+
label: "Header"
|
|
71
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuDivider.MenuDivider, {
|
|
72
|
+
dataTest: dividerDataTest
|
|
73
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
74
|
+
dataTest: menuItemDataTest,
|
|
75
|
+
label: "Menu item 1"
|
|
76
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
77
|
+
dataTest: menuItemDataTest,
|
|
78
|
+
label: "Menu item 2"
|
|
79
|
+
})));
|
|
80
|
+
const menu = getByRole('menu');
|
|
81
|
+
const divider = getByRole('separator');
|
|
82
|
+
const header = getByText(/Header/i);
|
|
83
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
84
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
85
|
+
expect(menu).not.toHaveFocus();
|
|
86
|
+
|
|
87
|
+
_userEvent.default.tab(); // check if LI parent node has focus or not
|
|
88
|
+
// headers and dividers do not receive focus
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
expect(header.parentNode.parentNode).not.toHaveFocus();
|
|
92
|
+
expect(divider.parentNode.parentNode).not.toHaveFocus();
|
|
93
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
94
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
95
|
+
});
|
|
96
|
+
it('can handle arrow down key navigation', async () => {
|
|
97
|
+
const {
|
|
98
|
+
getByText
|
|
99
|
+
} = (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
100
|
+
dataTest: menuDataTest,
|
|
101
|
+
dense: false
|
|
102
|
+
}, /*#__PURE__*/_react2.default.createElement(_menuSectionHeader.MenuSectionHeader, {
|
|
103
|
+
label: "Header"
|
|
104
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuDivider.MenuDivider, {
|
|
105
|
+
dataTest: dividerDataTest
|
|
106
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
107
|
+
dataTest: menuItemDataTest,
|
|
108
|
+
label: "Menu item 1"
|
|
109
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
110
|
+
dataTest: menuItemDataTest,
|
|
111
|
+
label: "Menu item 2"
|
|
112
|
+
})));
|
|
113
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
114
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
115
|
+
|
|
116
|
+
_userEvent.default.tab();
|
|
117
|
+
|
|
118
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus(); // simulate arrowDown press
|
|
119
|
+
|
|
120
|
+
_userEvent.default.keyboard('{ArrowDown}');
|
|
121
|
+
|
|
122
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus();
|
|
123
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus();
|
|
124
|
+
|
|
125
|
+
_userEvent.default.keyboard('{ArrowDown}');
|
|
126
|
+
|
|
127
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
128
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
129
|
+
});
|
|
130
|
+
it('can handle arrow up key navigation', async () => {
|
|
131
|
+
const {
|
|
132
|
+
getByText
|
|
133
|
+
} = (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
134
|
+
dataTest: menuDataTest,
|
|
135
|
+
dense: false
|
|
136
|
+
}, /*#__PURE__*/_react2.default.createElement(_menuSectionHeader.MenuSectionHeader, {
|
|
137
|
+
label: "Header"
|
|
138
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
139
|
+
dataTest: menuItemDataTest,
|
|
140
|
+
label: "Menu item 1"
|
|
141
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuDivider.MenuDivider, {
|
|
142
|
+
dataTest: dividerDataTest
|
|
143
|
+
}), /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
144
|
+
dataTest: menuItemDataTest,
|
|
145
|
+
label: "Menu item 2"
|
|
146
|
+
})));
|
|
147
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
148
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
149
|
+
|
|
150
|
+
_userEvent.default.tab();
|
|
151
|
+
|
|
152
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus(); // simulate arrowUp press
|
|
153
|
+
|
|
154
|
+
_userEvent.default.keyboard('{ArrowUp}');
|
|
155
|
+
|
|
156
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus();
|
|
157
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus();
|
|
158
|
+
|
|
159
|
+
_userEvent.default.keyboard('{ArrowUp}');
|
|
160
|
+
|
|
161
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
162
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
163
|
+
});
|
|
164
|
+
it('can handle space and enter key presses for clickable menu items', async () => {
|
|
165
|
+
const onClick = jest.fn();
|
|
166
|
+
const {
|
|
167
|
+
getByText
|
|
168
|
+
} = (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
169
|
+
dataTest: menuDataTest,
|
|
170
|
+
dense: false
|
|
171
|
+
}, /*#__PURE__*/_react2.default.createElement(_menuItem.MenuItem, {
|
|
172
|
+
onClick: onClick,
|
|
173
|
+
value: "myValue",
|
|
174
|
+
label: "Click menu item"
|
|
175
|
+
})));
|
|
176
|
+
const clickableItem = getByText(/Click menu item/i);
|
|
177
|
+
|
|
178
|
+
_userEvent.default.tab();
|
|
179
|
+
|
|
180
|
+
expect(clickableItem.parentNode.parentNode).toHaveFocus();
|
|
181
|
+
|
|
182
|
+
_userEvent.default.keyboard('[Space]');
|
|
183
|
+
|
|
184
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
185
|
+
|
|
186
|
+
_userEvent.default.keyboard('{Enter}');
|
|
187
|
+
|
|
188
|
+
expect(onClick).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
it('can handle non MenuItem components', () => {
|
|
191
|
+
const onClick = jest.fn();
|
|
192
|
+
const {
|
|
193
|
+
getByText
|
|
194
|
+
} = (0, _react.render)( /*#__PURE__*/_react2.default.createElement(_menu.Menu, {
|
|
195
|
+
dataTest: menuDataTest,
|
|
196
|
+
dense: false
|
|
197
|
+
}, /*#__PURE__*/_react2.default.createElement("span", {
|
|
198
|
+
role: "menuitem",
|
|
199
|
+
onClick: onClick
|
|
200
|
+
}, "Span 1"), /*#__PURE__*/_react2.default.createElement("li", {
|
|
201
|
+
tabIndex: -1
|
|
202
|
+
}, /*#__PURE__*/_react2.default.createElement("a", {
|
|
203
|
+
href: "#",
|
|
204
|
+
role: "menuitem"
|
|
205
|
+
}, "Link 2")), /*#__PURE__*/_react2.default.createElement("li", null, /*#__PURE__*/_react2.default.createElement("span", {
|
|
206
|
+
onClick: onClick
|
|
207
|
+
}, "Span 2"))));
|
|
208
|
+
const nonListMenuItem = getByText(/span 1/i);
|
|
209
|
+
const listMenuItem = getByText(/link 2/i);
|
|
210
|
+
const plainListItem = getByText(/span 2/i); // all children must be list items
|
|
211
|
+
|
|
212
|
+
expect(nonListMenuItem.parentElement.nodeName).toBe('LI');
|
|
213
|
+
|
|
214
|
+
_userEvent.default.tab();
|
|
215
|
+
|
|
216
|
+
expect(nonListMenuItem.parentElement).toHaveFocus();
|
|
217
|
+
expect(nonListMenuItem.parentElement.tabIndex).toBe(0);
|
|
218
|
+
expect(onClick).toHaveBeenCalledTimes(0);
|
|
219
|
+
|
|
220
|
+
_userEvent.default.keyboard('[Space]');
|
|
221
|
+
|
|
222
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
223
|
+
|
|
224
|
+
_userEvent.default.keyboard('{ArrowDown}');
|
|
225
|
+
|
|
226
|
+
expect(listMenuItem.parentElement).toHaveFocus();
|
|
227
|
+
|
|
228
|
+
_userEvent.default.keyboard('{ArrowDown}');
|
|
229
|
+
|
|
230
|
+
expect(nonListMenuItem.parentElement).toHaveFocus(); // non menu items do not receive focus
|
|
231
|
+
|
|
232
|
+
expect(plainListItem.parentElement).not.toHaveFocus();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.hasMenuItemRole = exports.getFocusableItemsIndices = void 0;
|
|
7
|
+
|
|
8
|
+
const isMenuItem = role => {
|
|
9
|
+
return ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(role);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const isValidMenuItemNode = node => {
|
|
13
|
+
if (node.nodeName === 'LI' && node.firstElementChild) {
|
|
14
|
+
return isValidMenuItemNode(node.firstElementChild);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const role = node.getAttribute('role');
|
|
18
|
+
return role && isMenuItem(role);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getFocusableItemsIndices = elements => {
|
|
22
|
+
const focusableIndices = [];
|
|
23
|
+
elements.forEach((node, index) => {
|
|
24
|
+
if (isValidMenuItemNode(node)) {
|
|
25
|
+
focusableIndices.push(index);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return focusableIndices;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
exports.getFocusableItemsIndices = getFocusableItemsIndices;
|
|
32
|
+
|
|
33
|
+
const hasMenuItemRole = component => {
|
|
34
|
+
var _component$props;
|
|
35
|
+
|
|
36
|
+
return isMenuItem(component === null || component === void 0 ? void 0 : (_component$props = component.props) === null || _component$props === void 0 ? void 0 : _component$props['role']);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
exports.hasMenuItemRole = hasMenuItemRole;
|
package/build/cjs/menu/menu.js
CHANGED
|
@@ -11,6 +11,10 @@ var _propTypes = _interopRequireDefault(require("prop-types"));
|
|
|
11
11
|
|
|
12
12
|
var _react = _interopRequireWildcard(require("react"));
|
|
13
13
|
|
|
14
|
+
var _helpers = require("./helpers.js");
|
|
15
|
+
|
|
16
|
+
var _useMenu = require("./use-menu.js");
|
|
17
|
+
|
|
14
18
|
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
|
|
15
19
|
|
|
16
20
|
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
|
|
@@ -24,15 +28,55 @@ const Menu = _ref => {
|
|
|
24
28
|
dataTest,
|
|
25
29
|
dense
|
|
26
30
|
} = _ref;
|
|
31
|
+
const {
|
|
32
|
+
menuRef,
|
|
33
|
+
focusedIndex
|
|
34
|
+
} = (0, _useMenu.useMenuNavigation)(children);
|
|
35
|
+
|
|
36
|
+
const childrenToRender = _react.Children.map(children, (child, index) => {
|
|
37
|
+
if (! /*#__PURE__*/(0, _react.isValidElement)(child)) {
|
|
38
|
+
return child;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tabIndex = index === focusedIndex ? 0 : -1;
|
|
42
|
+
const childProps = { ...child.props
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (typeof child.type === 'string') {
|
|
46
|
+
// remove non-native props from native HTML elements
|
|
47
|
+
delete childProps.hideDivider;
|
|
48
|
+
delete childProps.dense;
|
|
49
|
+
delete childProps.active; // all ul children must be li elements
|
|
50
|
+
// add tabindex for focus to those elements that are/contain a menuitem
|
|
51
|
+
|
|
52
|
+
if (child.type === 'li') {
|
|
53
|
+
return (0, _helpers.hasMenuItemRole)(child.props.children[0]) ? /*#__PURE__*/(0, _react.cloneElement)(child, { ...childProps,
|
|
54
|
+
tabIndex
|
|
55
|
+
}) : /*#__PURE__*/(0, _react.cloneElement)(child, childProps);
|
|
56
|
+
} else {
|
|
57
|
+
return /*#__PURE__*/_react.default.createElement("li", {
|
|
58
|
+
tabIndex: (0, _helpers.hasMenuItemRole)(child) ? tabIndex : null
|
|
59
|
+
}, /*#__PURE__*/(0, _react.cloneElement)(child, childProps));
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// assign non-native props to custom elements
|
|
63
|
+
childProps.dense = typeof child.props.dense === 'boolean' ? child.props.dense : dense;
|
|
64
|
+
childProps.hideDivider = typeof child.props.hideDivider !== 'boolean' && index === 0 ? true : child.props.hideDivider;
|
|
65
|
+
return /*#__PURE__*/(0, _react.cloneElement)(child, { ...childProps,
|
|
66
|
+
tabIndex
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
27
71
|
return /*#__PURE__*/_react.default.createElement("ul", {
|
|
28
72
|
"data-test": dataTest,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
id: "
|
|
35
|
-
}, ["ul.jsx-
|
|
73
|
+
role: "menu",
|
|
74
|
+
ref: menuRef,
|
|
75
|
+
tabIndex: 0,
|
|
76
|
+
className: "jsx-1636612837" + " " + (className || "")
|
|
77
|
+
}, childrenToRender, /*#__PURE__*/_react.default.createElement(_style.default, {
|
|
78
|
+
id: "1636612837"
|
|
79
|
+
}, ["ul.jsx-1636612837{display:block;position:relative;width:100%;margin:0;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}"]));
|
|
36
80
|
};
|
|
37
81
|
|
|
38
82
|
exports.Menu = Menu;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useMenuNavigation = void 0;
|
|
7
|
+
|
|
8
|
+
var _react = require("react");
|
|
9
|
+
|
|
10
|
+
var _helpers = require("./helpers.js");
|
|
11
|
+
|
|
12
|
+
const useMenuNavigation = children => {
|
|
13
|
+
const menuRef = (0, _react.useRef)(null);
|
|
14
|
+
const [focusableItemsIndices, setFocusableItemsIndices] = (0, _react.useState)(null);
|
|
15
|
+
const [activeItemIndex, setActiveItemIndex] = (0, _react.useState)(-1); // Initializes the indices for focusable items
|
|
16
|
+
|
|
17
|
+
(0, _react.useEffect)(() => {
|
|
18
|
+
if (menuRef) {
|
|
19
|
+
const menuItems = Array.from(menuRef.current.children);
|
|
20
|
+
const itemsIndices = (0, _helpers.getFocusableItemsIndices)(menuItems);
|
|
21
|
+
setFocusableItemsIndices(itemsIndices);
|
|
22
|
+
}
|
|
23
|
+
}, [children]); // Focus the active menu child
|
|
24
|
+
|
|
25
|
+
(0, _react.useEffect)(() => {
|
|
26
|
+
if (menuRef) {
|
|
27
|
+
if (focusableItemsIndices !== null && focusableItemsIndices !== void 0 && focusableItemsIndices.length && activeItemIndex > -1) {
|
|
28
|
+
const currentIndex = focusableItemsIndices[activeItemIndex];
|
|
29
|
+
menuRef.current.children[currentIndex].focus();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, [activeItemIndex, focusableItemsIndices]); // Navigate through focusable children using arrow keys
|
|
33
|
+
// Trigger actionable items
|
|
34
|
+
|
|
35
|
+
const handleKeyDown = (0, _react.useCallback)(event => {
|
|
36
|
+
const totalFocusablePositions = focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices.length;
|
|
37
|
+
|
|
38
|
+
if (totalFocusablePositions) {
|
|
39
|
+
const lastIndex = totalFocusablePositions - 1;
|
|
40
|
+
|
|
41
|
+
switch (event.key) {
|
|
42
|
+
case 'ArrowUp':
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
setActiveItemIndex(activeItemIndex > 0 ? activeItemIndex - 1 : lastIndex);
|
|
45
|
+
break;
|
|
46
|
+
|
|
47
|
+
case 'ArrowDown':
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
setActiveItemIndex(activeItemIndex >= lastIndex ? 0 : activeItemIndex + 1);
|
|
50
|
+
break;
|
|
51
|
+
|
|
52
|
+
case 'Enter':
|
|
53
|
+
case ' ':
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
|
|
56
|
+
if (event.target.nodeName === 'LI') {
|
|
57
|
+
event.target.children[0].click();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}, [activeItemIndex, focusableItemsIndices]); // Event listeners for menu focus and key handling
|
|
67
|
+
|
|
68
|
+
(0, _react.useEffect)(() => {
|
|
69
|
+
if (!menuRef) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const menu = menuRef.current; // Focus the first menu item when the menu receives focus
|
|
74
|
+
|
|
75
|
+
const handleFocus = event => {
|
|
76
|
+
if (event.target === menuRef.current) {
|
|
77
|
+
const firstItemIndex = focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices[0];
|
|
78
|
+
firstItemIndex && menuRef.current.children[firstItemIndex].focus();
|
|
79
|
+
setActiveItemIndex(0);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
menu.addEventListener('focus', handleFocus);
|
|
84
|
+
menu.addEventListener('keydown', handleKeyDown);
|
|
85
|
+
return () => {
|
|
86
|
+
menu.removeEventListener('focus', handleFocus);
|
|
87
|
+
menu.removeEventListener('keydown', handleKeyDown);
|
|
88
|
+
};
|
|
89
|
+
}, [activeItemIndex, focusableItemsIndices, handleKeyDown]);
|
|
90
|
+
return {
|
|
91
|
+
menuRef,
|
|
92
|
+
focusedIndex: focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices[activeItemIndex]
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
exports.useMenuNavigation = useMenuNavigation;
|
|
@@ -25,6 +25,7 @@ const MenuDivider = _ref => {
|
|
|
25
25
|
} = _ref;
|
|
26
26
|
return /*#__PURE__*/_react.default.createElement("li", {
|
|
27
27
|
"data-test": dataTest,
|
|
28
|
+
role: "separator",
|
|
28
29
|
className: _style.default.dynamic([["591815244", [_uiConstants.colors.white]]]) + " " + (className || "")
|
|
29
30
|
}, /*#__PURE__*/_react.default.createElement(_divider.Divider, {
|
|
30
31
|
dense: dense
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _enzyme = require("enzyme");
|
|
4
|
+
|
|
5
|
+
var _react = _interopRequireDefault(require("react"));
|
|
6
|
+
|
|
7
|
+
var _menuItem = require("../menu-item.js");
|
|
8
|
+
|
|
9
|
+
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
10
|
+
|
|
11
|
+
describe('Menu Component', () => {
|
|
12
|
+
it('Default menu item has role', () => {
|
|
13
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
14
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, {
|
|
15
|
+
dataTest: menuItemDataTest,
|
|
16
|
+
label: "Menu item"
|
|
17
|
+
}));
|
|
18
|
+
const menuItem = wrapper.find({
|
|
19
|
+
'data-test': menuItemDataTest
|
|
20
|
+
});
|
|
21
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitem');
|
|
22
|
+
expect(menuItem.childAt(0).prop('aria-disabled')).toBe(undefined);
|
|
23
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Menu item');
|
|
24
|
+
});
|
|
25
|
+
it('Disabled menu item has aria-disabled attribute', () => {
|
|
26
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
27
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, {
|
|
28
|
+
dataTest: menuItemDataTest,
|
|
29
|
+
label: "Disabled menu item",
|
|
30
|
+
disabled: true
|
|
31
|
+
}));
|
|
32
|
+
const menuItem = wrapper.find({
|
|
33
|
+
'data-test': menuItemDataTest
|
|
34
|
+
});
|
|
35
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitem');
|
|
36
|
+
expect(menuItem.childAt(0).prop('aria-disabled')).toBe(true);
|
|
37
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Disabled menu item');
|
|
38
|
+
});
|
|
39
|
+
it('Toggle-able menu item has menuitemcheckbox role', () => {
|
|
40
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
41
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, {
|
|
42
|
+
dataTest: menuItemDataTest,
|
|
43
|
+
label: "Toggle-able menu item",
|
|
44
|
+
checkbox: true,
|
|
45
|
+
checked: false
|
|
46
|
+
}));
|
|
47
|
+
const menuItem = wrapper.find({
|
|
48
|
+
'data-test': menuItemDataTest
|
|
49
|
+
});
|
|
50
|
+
expect(menuItem.childAt(0).prop('role')).not.toBe('menuitem');
|
|
51
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitemcheckbox');
|
|
52
|
+
expect(menuItem.childAt(0).prop('aria-checked')).toBe(false);
|
|
53
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Toggle-able menu item');
|
|
54
|
+
});
|
|
55
|
+
it('Submenu has relevant aria attributes', () => {
|
|
56
|
+
const showSubMenu = false;
|
|
57
|
+
const wrapper = (0, _enzyme.mount)( /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, {
|
|
58
|
+
showSubMenu: showSubMenu,
|
|
59
|
+
toggleSubMenu: () => jest.fn(),
|
|
60
|
+
label: "Parent of submenus"
|
|
61
|
+
}, /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, {
|
|
62
|
+
label: "Test submenu child"
|
|
63
|
+
})));
|
|
64
|
+
const menuItem = wrapper.find({
|
|
65
|
+
role: 'menuitem'
|
|
66
|
+
});
|
|
67
|
+
expect(menuItem.prop('aria-haspopup')).toBe('menu');
|
|
68
|
+
expect(menuItem.prop('aria-expanded')).toBe(false);
|
|
69
|
+
expect(menuItem.prop('aria-label')).toBe('Parent of submenus');
|
|
70
|
+
expect(wrapper.find({
|
|
71
|
+
label: 'Test submenu child'
|
|
72
|
+
}).exists()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -70,17 +70,22 @@ const MenuItem = _ref2 => {
|
|
|
70
70
|
label,
|
|
71
71
|
showSubMenu,
|
|
72
72
|
toggleSubMenu,
|
|
73
|
-
suffix
|
|
73
|
+
suffix,
|
|
74
|
+
checkbox,
|
|
75
|
+
checked,
|
|
76
|
+
tabIndex
|
|
74
77
|
} = _ref2;
|
|
75
78
|
const menuItemRef = (0, _react.useRef)();
|
|
76
79
|
return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement("li", {
|
|
77
80
|
ref: menuItemRef,
|
|
78
81
|
"data-test": dataTest,
|
|
82
|
+
role: "presentation",
|
|
83
|
+
tabIndex: tabIndex,
|
|
79
84
|
className: "jsx-".concat(_menuItemStyles.default.__hash) + " " + ((0, _classnames.default)(className, {
|
|
80
85
|
destructive,
|
|
81
86
|
disabled,
|
|
82
87
|
dense,
|
|
83
|
-
active: active || showSubMenu,
|
|
88
|
+
active: active || showSubMenu || tabIndex === 0,
|
|
84
89
|
'with-chevron': children || chevron
|
|
85
90
|
}) || "")
|
|
86
91
|
}, /*#__PURE__*/_react.default.createElement("a", {
|
|
@@ -92,6 +97,12 @@ const MenuItem = _ref2 => {
|
|
|
92
97
|
isLink: !!href,
|
|
93
98
|
value
|
|
94
99
|
}) : undefined,
|
|
100
|
+
role: checkbox ? 'menuitemcheckbox' : 'menuitem',
|
|
101
|
+
"aria-checked": checkbox ? checked : null,
|
|
102
|
+
"aria-disabled": disabled,
|
|
103
|
+
"aria-haspopup": children && 'menu',
|
|
104
|
+
"aria-expanded": showSubMenu,
|
|
105
|
+
"aria-label": label,
|
|
95
106
|
className: "jsx-".concat(_menuItemStyles.default.__hash)
|
|
96
107
|
}, icon && /*#__PURE__*/_react.default.createElement("span", {
|
|
97
108
|
className: "jsx-".concat(_menuItemStyles.default.__hash) + " " + "icon"
|
|
@@ -117,6 +128,8 @@ MenuItem.defaultProps = {
|
|
|
117
128
|
};
|
|
118
129
|
MenuItem.propTypes = {
|
|
119
130
|
active: _propTypes.default.bool,
|
|
131
|
+
checkbox: _propTypes.default.bool,
|
|
132
|
+
checked: _propTypes.default.bool,
|
|
120
133
|
chevron: _propTypes.default.bool,
|
|
121
134
|
|
|
122
135
|
/**
|
|
@@ -144,6 +157,7 @@ MenuItem.propTypes = {
|
|
|
144
157
|
|
|
145
158
|
/** A supporting element shown at the end of the menu item */
|
|
146
159
|
suffix: _propTypes.default.node,
|
|
160
|
+
tabIndex: _propTypes.default.number,
|
|
147
161
|
|
|
148
162
|
/** For using menu item as a link */
|
|
149
163
|
target: _propTypes.default.string,
|
|
@@ -168,7 +168,9 @@ const ToggleMenuItem = args => {
|
|
|
168
168
|
return /*#__PURE__*/_react.default.createElement(_index.Menu, null, /*#__PURE__*/_react.default.createElement(_menuItem.MenuItem, _extends({}, args, {
|
|
169
169
|
onClick: toggleOn,
|
|
170
170
|
icon: icon,
|
|
171
|
-
label: "A toggle menu item"
|
|
171
|
+
label: "A toggle menu item",
|
|
172
|
+
checkbox: true,
|
|
173
|
+
checked: on
|
|
172
174
|
})));
|
|
173
175
|
};
|
|
174
176
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { mount } from 'enzyme';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { MenuDivider } from '../../menu-divider/menu-divider.js';
|
|
6
|
+
import { MenuItem } from '../../menu-item/menu-item.js';
|
|
7
|
+
import { MenuSectionHeader } from '../../menu-section-header/menu-section-header.js';
|
|
8
|
+
import { Menu } from '../menu.js';
|
|
9
|
+
describe('Menu Component', () => {
|
|
10
|
+
const menuDataTest = 'data-test-menu';
|
|
11
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
12
|
+
const dividerDataTest = 'data-test-menu-divider';
|
|
13
|
+
it('Menu and menu items have aria attributes', () => {
|
|
14
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(Menu, {
|
|
15
|
+
dataTest: menuDataTest,
|
|
16
|
+
dense: false
|
|
17
|
+
}, /*#__PURE__*/React.createElement(MenuSectionHeader, {
|
|
18
|
+
label: "Header"
|
|
19
|
+
}), /*#__PURE__*/React.createElement(MenuDivider, {
|
|
20
|
+
dataTest: dividerDataTest
|
|
21
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
22
|
+
dataTest: menuItemDataTest,
|
|
23
|
+
label: "Menu item"
|
|
24
|
+
})));
|
|
25
|
+
const menuElement = wrapper.find({
|
|
26
|
+
'data-test': menuDataTest
|
|
27
|
+
});
|
|
28
|
+
const menuItem = wrapper.find({
|
|
29
|
+
'data-test': menuItemDataTest
|
|
30
|
+
});
|
|
31
|
+
const menuDivider = wrapper.find({
|
|
32
|
+
'data-test': dividerDataTest
|
|
33
|
+
});
|
|
34
|
+
expect(menuElement.prop('role')).toBe('menu');
|
|
35
|
+
expect(menuItem.childAt(0).props().role).toBe('menuitem');
|
|
36
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Menu item');
|
|
37
|
+
expect(menuDivider.prop('role')).toBe('separator');
|
|
38
|
+
});
|
|
39
|
+
it('Empty menu has role menu', () => {
|
|
40
|
+
const menuDataTest = 'data-test-menu';
|
|
41
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(Menu, {
|
|
42
|
+
dataTest: menuDataTest,
|
|
43
|
+
dense: false
|
|
44
|
+
}));
|
|
45
|
+
const menuElement = wrapper.find({
|
|
46
|
+
'data-test': menuDataTest
|
|
47
|
+
});
|
|
48
|
+
expect(menuElement.prop('role')).toBe('menu');
|
|
49
|
+
});
|
|
50
|
+
it('can handle focus of first focusable element when tabbed to', () => {
|
|
51
|
+
const {
|
|
52
|
+
getByRole,
|
|
53
|
+
getByText
|
|
54
|
+
} = render( /*#__PURE__*/React.createElement(Menu, {
|
|
55
|
+
dataTest: menuDataTest,
|
|
56
|
+
dense: false
|
|
57
|
+
}, /*#__PURE__*/React.createElement(MenuSectionHeader, {
|
|
58
|
+
label: "Header"
|
|
59
|
+
}), /*#__PURE__*/React.createElement(MenuDivider, {
|
|
60
|
+
dataTest: dividerDataTest
|
|
61
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
62
|
+
dataTest: menuItemDataTest,
|
|
63
|
+
label: "Menu item 1"
|
|
64
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
65
|
+
dataTest: menuItemDataTest,
|
|
66
|
+
label: "Menu item 2"
|
|
67
|
+
})));
|
|
68
|
+
const menu = getByRole('menu');
|
|
69
|
+
const divider = getByRole('separator');
|
|
70
|
+
const header = getByText(/Header/i);
|
|
71
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
72
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
73
|
+
expect(menu).not.toHaveFocus();
|
|
74
|
+
userEvent.tab(); // check if LI parent node has focus or not
|
|
75
|
+
// headers and dividers do not receive focus
|
|
76
|
+
|
|
77
|
+
expect(header.parentNode.parentNode).not.toHaveFocus();
|
|
78
|
+
expect(divider.parentNode.parentNode).not.toHaveFocus();
|
|
79
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
80
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
81
|
+
});
|
|
82
|
+
it('can handle arrow down key navigation', async () => {
|
|
83
|
+
const {
|
|
84
|
+
getByText
|
|
85
|
+
} = render( /*#__PURE__*/React.createElement(Menu, {
|
|
86
|
+
dataTest: menuDataTest,
|
|
87
|
+
dense: false
|
|
88
|
+
}, /*#__PURE__*/React.createElement(MenuSectionHeader, {
|
|
89
|
+
label: "Header"
|
|
90
|
+
}), /*#__PURE__*/React.createElement(MenuDivider, {
|
|
91
|
+
dataTest: dividerDataTest
|
|
92
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
93
|
+
dataTest: menuItemDataTest,
|
|
94
|
+
label: "Menu item 1"
|
|
95
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
96
|
+
dataTest: menuItemDataTest,
|
|
97
|
+
label: "Menu item 2"
|
|
98
|
+
})));
|
|
99
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
100
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
101
|
+
userEvent.tab();
|
|
102
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus(); // simulate arrowDown press
|
|
103
|
+
|
|
104
|
+
userEvent.keyboard('{ArrowDown}');
|
|
105
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus();
|
|
106
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus();
|
|
107
|
+
userEvent.keyboard('{ArrowDown}');
|
|
108
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
109
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
110
|
+
});
|
|
111
|
+
it('can handle arrow up key navigation', async () => {
|
|
112
|
+
const {
|
|
113
|
+
getByText
|
|
114
|
+
} = render( /*#__PURE__*/React.createElement(Menu, {
|
|
115
|
+
dataTest: menuDataTest,
|
|
116
|
+
dense: false
|
|
117
|
+
}, /*#__PURE__*/React.createElement(MenuSectionHeader, {
|
|
118
|
+
label: "Header"
|
|
119
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
120
|
+
dataTest: menuItemDataTest,
|
|
121
|
+
label: "Menu item 1"
|
|
122
|
+
}), /*#__PURE__*/React.createElement(MenuDivider, {
|
|
123
|
+
dataTest: dividerDataTest
|
|
124
|
+
}), /*#__PURE__*/React.createElement(MenuItem, {
|
|
125
|
+
dataTest: menuItemDataTest,
|
|
126
|
+
label: "Menu item 2"
|
|
127
|
+
})));
|
|
128
|
+
const menuItem1 = getByText(/Menu item 1/i);
|
|
129
|
+
const menuItem2 = getByText(/Menu item 2/i);
|
|
130
|
+
userEvent.tab();
|
|
131
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus(); // simulate arrowUp press
|
|
132
|
+
|
|
133
|
+
userEvent.keyboard('{ArrowUp}');
|
|
134
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus();
|
|
135
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus();
|
|
136
|
+
userEvent.keyboard('{ArrowUp}');
|
|
137
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus();
|
|
138
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus();
|
|
139
|
+
});
|
|
140
|
+
it('can handle space and enter key presses for clickable menu items', async () => {
|
|
141
|
+
const onClick = jest.fn();
|
|
142
|
+
const {
|
|
143
|
+
getByText
|
|
144
|
+
} = render( /*#__PURE__*/React.createElement(Menu, {
|
|
145
|
+
dataTest: menuDataTest,
|
|
146
|
+
dense: false
|
|
147
|
+
}, /*#__PURE__*/React.createElement(MenuItem, {
|
|
148
|
+
onClick: onClick,
|
|
149
|
+
value: "myValue",
|
|
150
|
+
label: "Click menu item"
|
|
151
|
+
})));
|
|
152
|
+
const clickableItem = getByText(/Click menu item/i);
|
|
153
|
+
userEvent.tab();
|
|
154
|
+
expect(clickableItem.parentNode.parentNode).toHaveFocus();
|
|
155
|
+
userEvent.keyboard('[Space]');
|
|
156
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
157
|
+
userEvent.keyboard('{Enter}');
|
|
158
|
+
expect(onClick).toHaveBeenCalledTimes(2);
|
|
159
|
+
});
|
|
160
|
+
it('can handle non MenuItem components', () => {
|
|
161
|
+
const onClick = jest.fn();
|
|
162
|
+
const {
|
|
163
|
+
getByText
|
|
164
|
+
} = render( /*#__PURE__*/React.createElement(Menu, {
|
|
165
|
+
dataTest: menuDataTest,
|
|
166
|
+
dense: false
|
|
167
|
+
}, /*#__PURE__*/React.createElement("span", {
|
|
168
|
+
role: "menuitem",
|
|
169
|
+
onClick: onClick
|
|
170
|
+
}, "Span 1"), /*#__PURE__*/React.createElement("li", {
|
|
171
|
+
tabIndex: -1
|
|
172
|
+
}, /*#__PURE__*/React.createElement("a", {
|
|
173
|
+
href: "#",
|
|
174
|
+
role: "menuitem"
|
|
175
|
+
}, "Link 2")), /*#__PURE__*/React.createElement("li", null, /*#__PURE__*/React.createElement("span", {
|
|
176
|
+
onClick: onClick
|
|
177
|
+
}, "Span 2"))));
|
|
178
|
+
const nonListMenuItem = getByText(/span 1/i);
|
|
179
|
+
const listMenuItem = getByText(/link 2/i);
|
|
180
|
+
const plainListItem = getByText(/span 2/i); // all children must be list items
|
|
181
|
+
|
|
182
|
+
expect(nonListMenuItem.parentElement.nodeName).toBe('LI');
|
|
183
|
+
userEvent.tab();
|
|
184
|
+
expect(nonListMenuItem.parentElement).toHaveFocus();
|
|
185
|
+
expect(nonListMenuItem.parentElement.tabIndex).toBe(0);
|
|
186
|
+
expect(onClick).toHaveBeenCalledTimes(0);
|
|
187
|
+
userEvent.keyboard('[Space]');
|
|
188
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
189
|
+
userEvent.keyboard('{ArrowDown}');
|
|
190
|
+
expect(listMenuItem.parentElement).toHaveFocus();
|
|
191
|
+
userEvent.keyboard('{ArrowDown}');
|
|
192
|
+
expect(nonListMenuItem.parentElement).toHaveFocus(); // non menu items do not receive focus
|
|
193
|
+
|
|
194
|
+
expect(plainListItem.parentElement).not.toHaveFocus();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const isMenuItem = role => {
|
|
2
|
+
return ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(role);
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
const isValidMenuItemNode = node => {
|
|
6
|
+
if (node.nodeName === 'LI' && node.firstElementChild) {
|
|
7
|
+
return isValidMenuItemNode(node.firstElementChild);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const role = node.getAttribute('role');
|
|
11
|
+
return role && isMenuItem(role);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const getFocusableItemsIndices = elements => {
|
|
15
|
+
const focusableIndices = [];
|
|
16
|
+
elements.forEach((node, index) => {
|
|
17
|
+
if (isValidMenuItemNode(node)) {
|
|
18
|
+
focusableIndices.push(index);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return focusableIndices;
|
|
22
|
+
};
|
|
23
|
+
export const hasMenuItemRole = component => {
|
|
24
|
+
var _component$props;
|
|
25
|
+
|
|
26
|
+
return isMenuItem(component === null || component === void 0 ? void 0 : (_component$props = component.props) === null || _component$props === void 0 ? void 0 : _component$props['role']);
|
|
27
|
+
};
|
package/build/es/menu/menu.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import _JSXStyle from "styled-jsx/style";
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
import React, { Children, cloneElement, isValidElement } from 'react';
|
|
4
|
+
import { hasMenuItemRole } from './helpers.js';
|
|
5
|
+
import { useMenuNavigation } from './use-menu.js';
|
|
4
6
|
|
|
5
7
|
const Menu = _ref => {
|
|
6
8
|
let {
|
|
@@ -9,15 +11,53 @@ const Menu = _ref => {
|
|
|
9
11
|
dataTest,
|
|
10
12
|
dense
|
|
11
13
|
} = _ref;
|
|
14
|
+
const {
|
|
15
|
+
menuRef,
|
|
16
|
+
focusedIndex
|
|
17
|
+
} = useMenuNavigation(children);
|
|
18
|
+
const childrenToRender = Children.map(children, (child, index) => {
|
|
19
|
+
if (! /*#__PURE__*/isValidElement(child)) {
|
|
20
|
+
return child;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tabIndex = index === focusedIndex ? 0 : -1;
|
|
24
|
+
const childProps = { ...child.props
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (typeof child.type === 'string') {
|
|
28
|
+
// remove non-native props from native HTML elements
|
|
29
|
+
delete childProps.hideDivider;
|
|
30
|
+
delete childProps.dense;
|
|
31
|
+
delete childProps.active; // all ul children must be li elements
|
|
32
|
+
// add tabindex for focus to those elements that are/contain a menuitem
|
|
33
|
+
|
|
34
|
+
if (child.type === 'li') {
|
|
35
|
+
return hasMenuItemRole(child.props.children[0]) ? /*#__PURE__*/cloneElement(child, { ...childProps,
|
|
36
|
+
tabIndex
|
|
37
|
+
}) : /*#__PURE__*/cloneElement(child, childProps);
|
|
38
|
+
} else {
|
|
39
|
+
return /*#__PURE__*/React.createElement("li", {
|
|
40
|
+
tabIndex: hasMenuItemRole(child) ? tabIndex : null
|
|
41
|
+
}, /*#__PURE__*/cloneElement(child, childProps));
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// assign non-native props to custom elements
|
|
45
|
+
childProps.dense = typeof child.props.dense === 'boolean' ? child.props.dense : dense;
|
|
46
|
+
childProps.hideDivider = typeof child.props.hideDivider !== 'boolean' && index === 0 ? true : child.props.hideDivider;
|
|
47
|
+
return /*#__PURE__*/cloneElement(child, { ...childProps,
|
|
48
|
+
tabIndex
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
12
52
|
return /*#__PURE__*/React.createElement("ul", {
|
|
13
53
|
"data-test": dataTest,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
id: "
|
|
20
|
-
}, ["ul.jsx-
|
|
54
|
+
role: "menu",
|
|
55
|
+
ref: menuRef,
|
|
56
|
+
tabIndex: 0,
|
|
57
|
+
className: "jsx-1636612837" + " " + (className || "")
|
|
58
|
+
}, childrenToRender, /*#__PURE__*/React.createElement(_JSXStyle, {
|
|
59
|
+
id: "1636612837"
|
|
60
|
+
}, ["ul.jsx-1636612837{display:block;position:relative;width:100%;margin:0;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}"]));
|
|
21
61
|
};
|
|
22
62
|
|
|
23
63
|
Menu.defaultProps = {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { getFocusableItemsIndices } from './helpers.js';
|
|
3
|
+
export const useMenuNavigation = children => {
|
|
4
|
+
const menuRef = useRef(null);
|
|
5
|
+
const [focusableItemsIndices, setFocusableItemsIndices] = useState(null);
|
|
6
|
+
const [activeItemIndex, setActiveItemIndex] = useState(-1); // Initializes the indices for focusable items
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (menuRef) {
|
|
10
|
+
const menuItems = Array.from(menuRef.current.children);
|
|
11
|
+
const itemsIndices = getFocusableItemsIndices(menuItems);
|
|
12
|
+
setFocusableItemsIndices(itemsIndices);
|
|
13
|
+
}
|
|
14
|
+
}, [children]); // Focus the active menu child
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (menuRef) {
|
|
18
|
+
if (focusableItemsIndices !== null && focusableItemsIndices !== void 0 && focusableItemsIndices.length && activeItemIndex > -1) {
|
|
19
|
+
const currentIndex = focusableItemsIndices[activeItemIndex];
|
|
20
|
+
menuRef.current.children[currentIndex].focus();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}, [activeItemIndex, focusableItemsIndices]); // Navigate through focusable children using arrow keys
|
|
24
|
+
// Trigger actionable items
|
|
25
|
+
|
|
26
|
+
const handleKeyDown = useCallback(event => {
|
|
27
|
+
const totalFocusablePositions = focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices.length;
|
|
28
|
+
|
|
29
|
+
if (totalFocusablePositions) {
|
|
30
|
+
const lastIndex = totalFocusablePositions - 1;
|
|
31
|
+
|
|
32
|
+
switch (event.key) {
|
|
33
|
+
case 'ArrowUp':
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
setActiveItemIndex(activeItemIndex > 0 ? activeItemIndex - 1 : lastIndex);
|
|
36
|
+
break;
|
|
37
|
+
|
|
38
|
+
case 'ArrowDown':
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
setActiveItemIndex(activeItemIndex >= lastIndex ? 0 : activeItemIndex + 1);
|
|
41
|
+
break;
|
|
42
|
+
|
|
43
|
+
case 'Enter':
|
|
44
|
+
case ' ':
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
|
|
47
|
+
if (event.target.nodeName === 'LI') {
|
|
48
|
+
event.target.children[0].click();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
break;
|
|
52
|
+
|
|
53
|
+
default:
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, [activeItemIndex, focusableItemsIndices]); // Event listeners for menu focus and key handling
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!menuRef) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const menu = menuRef.current; // Focus the first menu item when the menu receives focus
|
|
65
|
+
|
|
66
|
+
const handleFocus = event => {
|
|
67
|
+
if (event.target === menuRef.current) {
|
|
68
|
+
const firstItemIndex = focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices[0];
|
|
69
|
+
firstItemIndex && menuRef.current.children[firstItemIndex].focus();
|
|
70
|
+
setActiveItemIndex(0);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
menu.addEventListener('focus', handleFocus);
|
|
75
|
+
menu.addEventListener('keydown', handleKeyDown);
|
|
76
|
+
return () => {
|
|
77
|
+
menu.removeEventListener('focus', handleFocus);
|
|
78
|
+
menu.removeEventListener('keydown', handleKeyDown);
|
|
79
|
+
};
|
|
80
|
+
}, [activeItemIndex, focusableItemsIndices, handleKeyDown]);
|
|
81
|
+
return {
|
|
82
|
+
menuRef,
|
|
83
|
+
focusedIndex: focusableItemsIndices === null || focusableItemsIndices === void 0 ? void 0 : focusableItemsIndices[activeItemIndex]
|
|
84
|
+
};
|
|
85
|
+
};
|
|
@@ -12,6 +12,7 @@ const MenuDivider = _ref => {
|
|
|
12
12
|
} = _ref;
|
|
13
13
|
return /*#__PURE__*/React.createElement("li", {
|
|
14
14
|
"data-test": dataTest,
|
|
15
|
+
role: "separator",
|
|
15
16
|
className: _JSXStyle.dynamic([["591815244", [colors.white]]]) + " " + (className || "")
|
|
16
17
|
}, /*#__PURE__*/React.createElement(Divider, {
|
|
17
18
|
dense: dense
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mount } from 'enzyme';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { MenuItem } from '../menu-item.js';
|
|
4
|
+
describe('Menu Component', () => {
|
|
5
|
+
it('Default menu item has role', () => {
|
|
6
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
7
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(MenuItem, {
|
|
8
|
+
dataTest: menuItemDataTest,
|
|
9
|
+
label: "Menu item"
|
|
10
|
+
}));
|
|
11
|
+
const menuItem = wrapper.find({
|
|
12
|
+
'data-test': menuItemDataTest
|
|
13
|
+
});
|
|
14
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitem');
|
|
15
|
+
expect(menuItem.childAt(0).prop('aria-disabled')).toBe(undefined);
|
|
16
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Menu item');
|
|
17
|
+
});
|
|
18
|
+
it('Disabled menu item has aria-disabled attribute', () => {
|
|
19
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
20
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(MenuItem, {
|
|
21
|
+
dataTest: menuItemDataTest,
|
|
22
|
+
label: "Disabled menu item",
|
|
23
|
+
disabled: true
|
|
24
|
+
}));
|
|
25
|
+
const menuItem = wrapper.find({
|
|
26
|
+
'data-test': menuItemDataTest
|
|
27
|
+
});
|
|
28
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitem');
|
|
29
|
+
expect(menuItem.childAt(0).prop('aria-disabled')).toBe(true);
|
|
30
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Disabled menu item');
|
|
31
|
+
});
|
|
32
|
+
it('Toggle-able menu item has menuitemcheckbox role', () => {
|
|
33
|
+
const menuItemDataTest = 'data-test-menu-item';
|
|
34
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(MenuItem, {
|
|
35
|
+
dataTest: menuItemDataTest,
|
|
36
|
+
label: "Toggle-able menu item",
|
|
37
|
+
checkbox: true,
|
|
38
|
+
checked: false
|
|
39
|
+
}));
|
|
40
|
+
const menuItem = wrapper.find({
|
|
41
|
+
'data-test': menuItemDataTest
|
|
42
|
+
});
|
|
43
|
+
expect(menuItem.childAt(0).prop('role')).not.toBe('menuitem');
|
|
44
|
+
expect(menuItem.childAt(0).prop('role')).toBe('menuitemcheckbox');
|
|
45
|
+
expect(menuItem.childAt(0).prop('aria-checked')).toBe(false);
|
|
46
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Toggle-able menu item');
|
|
47
|
+
});
|
|
48
|
+
it('Submenu has relevant aria attributes', () => {
|
|
49
|
+
const showSubMenu = false;
|
|
50
|
+
const wrapper = mount( /*#__PURE__*/React.createElement(MenuItem, {
|
|
51
|
+
showSubMenu: showSubMenu,
|
|
52
|
+
toggleSubMenu: () => jest.fn(),
|
|
53
|
+
label: "Parent of submenus"
|
|
54
|
+
}, /*#__PURE__*/React.createElement(MenuItem, {
|
|
55
|
+
label: "Test submenu child"
|
|
56
|
+
})));
|
|
57
|
+
const menuItem = wrapper.find({
|
|
58
|
+
role: 'menuitem'
|
|
59
|
+
});
|
|
60
|
+
expect(menuItem.prop('aria-haspopup')).toBe('menu');
|
|
61
|
+
expect(menuItem.prop('aria-expanded')).toBe(false);
|
|
62
|
+
expect(menuItem.prop('aria-label')).toBe('Parent of submenus');
|
|
63
|
+
expect(wrapper.find({
|
|
64
|
+
label: 'Test submenu child'
|
|
65
|
+
}).exists()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -49,17 +49,22 @@ const MenuItem = _ref2 => {
|
|
|
49
49
|
label,
|
|
50
50
|
showSubMenu,
|
|
51
51
|
toggleSubMenu,
|
|
52
|
-
suffix
|
|
52
|
+
suffix,
|
|
53
|
+
checkbox,
|
|
54
|
+
checked,
|
|
55
|
+
tabIndex
|
|
53
56
|
} = _ref2;
|
|
54
57
|
const menuItemRef = useRef();
|
|
55
58
|
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("li", {
|
|
56
59
|
ref: menuItemRef,
|
|
57
60
|
"data-test": dataTest,
|
|
61
|
+
role: "presentation",
|
|
62
|
+
tabIndex: tabIndex,
|
|
58
63
|
className: "jsx-".concat(styles.__hash) + " " + (cx(className, {
|
|
59
64
|
destructive,
|
|
60
65
|
disabled,
|
|
61
66
|
dense,
|
|
62
|
-
active: active || showSubMenu,
|
|
67
|
+
active: active || showSubMenu || tabIndex === 0,
|
|
63
68
|
'with-chevron': children || chevron
|
|
64
69
|
}) || "")
|
|
65
70
|
}, /*#__PURE__*/React.createElement("a", {
|
|
@@ -71,6 +76,12 @@ const MenuItem = _ref2 => {
|
|
|
71
76
|
isLink: !!href,
|
|
72
77
|
value
|
|
73
78
|
}) : undefined,
|
|
79
|
+
role: checkbox ? 'menuitemcheckbox' : 'menuitem',
|
|
80
|
+
"aria-checked": checkbox ? checked : null,
|
|
81
|
+
"aria-disabled": disabled,
|
|
82
|
+
"aria-haspopup": children && 'menu',
|
|
83
|
+
"aria-expanded": showSubMenu,
|
|
84
|
+
"aria-label": label,
|
|
74
85
|
className: "jsx-".concat(styles.__hash)
|
|
75
86
|
}, icon && /*#__PURE__*/React.createElement("span", {
|
|
76
87
|
className: "jsx-".concat(styles.__hash) + " " + "icon"
|
|
@@ -95,6 +106,8 @@ MenuItem.defaultProps = {
|
|
|
95
106
|
};
|
|
96
107
|
MenuItem.propTypes = {
|
|
97
108
|
active: PropTypes.bool,
|
|
109
|
+
checkbox: PropTypes.bool,
|
|
110
|
+
checked: PropTypes.bool,
|
|
98
111
|
chevron: PropTypes.bool,
|
|
99
112
|
|
|
100
113
|
/**
|
|
@@ -122,6 +135,7 @@ MenuItem.propTypes = {
|
|
|
122
135
|
|
|
123
136
|
/** A supporting element shown at the end of the menu item */
|
|
124
137
|
suffix: PropTypes.node,
|
|
138
|
+
tabIndex: PropTypes.number,
|
|
125
139
|
|
|
126
140
|
/** For using menu item as a link */
|
|
127
141
|
target: PropTypes.string,
|
|
@@ -134,7 +134,9 @@ export const ToggleMenuItem = args => {
|
|
|
134
134
|
return /*#__PURE__*/React.createElement(Menu, null, /*#__PURE__*/React.createElement(MenuItem, _extends({}, args, {
|
|
135
135
|
onClick: toggleOn,
|
|
136
136
|
icon: icon,
|
|
137
|
-
label: "A toggle menu item"
|
|
137
|
+
label: "A toggle menu item",
|
|
138
|
+
checkbox: true,
|
|
139
|
+
checked: on
|
|
138
140
|
})));
|
|
139
141
|
};
|
|
140
142
|
ToggleMenuItem.parameters = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhis2-ui/menu",
|
|
3
|
-
"version": "9.9.
|
|
3
|
+
"version": "9.9.1",
|
|
4
4
|
"description": "UI Menu",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@dhis2/prop-types": "^3.1.2",
|
|
36
|
-
"@dhis2-ui/card": "9.9.
|
|
37
|
-
"@dhis2-ui/divider": "9.9.
|
|
38
|
-
"@dhis2-ui/layer": "9.9.
|
|
39
|
-
"@dhis2-ui/popper": "9.9.
|
|
40
|
-
"@dhis2-ui/portal": "9.9.
|
|
41
|
-
"@dhis2/ui-constants": "9.9.
|
|
42
|
-
"@dhis2/ui-icons": "9.9.
|
|
36
|
+
"@dhis2-ui/card": "9.9.1",
|
|
37
|
+
"@dhis2-ui/divider": "9.9.1",
|
|
38
|
+
"@dhis2-ui/layer": "9.9.1",
|
|
39
|
+
"@dhis2-ui/popper": "9.9.1",
|
|
40
|
+
"@dhis2-ui/portal": "9.9.1",
|
|
41
|
+
"@dhis2/ui-constants": "9.9.1",
|
|
42
|
+
"@dhis2/ui-icons": "9.9.1",
|
|
43
43
|
"classnames": "^2.3.1",
|
|
44
44
|
"prop-types": "^15.7.2"
|
|
45
45
|
},
|
package/types/index.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export const MenuDivider: React.FC<MenuDividerProps>
|
|
|
42
42
|
|
|
43
43
|
export interface MenuItemProps {
|
|
44
44
|
active?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Specifies if menu item is a checkbox
|
|
47
|
+
*/
|
|
48
|
+
checkbox?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* checkbox state for toggleable menu items
|
|
51
|
+
*/
|
|
52
|
+
checked?: boolean
|
|
45
53
|
chevron?: boolean
|
|
46
54
|
/**
|
|
47
55
|
* Nested menu items can become submenus.
|
|
@@ -69,6 +77,11 @@ export interface MenuItemProps {
|
|
|
69
77
|
* When true, nested menu items are shown in a Popper
|
|
70
78
|
*/
|
|
71
79
|
showSubMenu?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* A supporting element shown at the end of the menu item
|
|
82
|
+
*/
|
|
83
|
+
suffix: React.ReactNode
|
|
84
|
+
tabIndex?: number
|
|
72
85
|
/**
|
|
73
86
|
* For using menu item as a link
|
|
74
87
|
*/
|