@dhis2-ui/menu 10.16.2 → 10.16.3
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/package.json +10 -9
- package/src/flyout-menu/__tests__/flyout-menu.test.js +48 -0
- package/src/flyout-menu/features/accepts_children/index.js +10 -0
- package/src/flyout-menu/features/accepts_children.feature +5 -0
- package/src/flyout-menu/features/position/index.js +80 -0
- package/src/flyout-menu/features/position.feature +19 -0
- package/src/flyout-menu/features/toggles_submenus/index.js +26 -0
- package/src/flyout-menu/features/toggles_submenus.feature +9 -0
- package/src/flyout-menu/flyout-menu.e2e.stories.js +55 -0
- package/src/flyout-menu/flyout-menu.js +108 -0
- package/src/flyout-menu/flyout-menu.prod.stories.js +229 -0
- package/src/flyout-menu/index.js +1 -0
- package/src/index.js +6 -0
- package/src/menu/__tests__/menu.test.js +213 -0
- package/src/menu/features/accepts_children/index.js +10 -0
- package/src/menu/features/accepts_children.feature +5 -0
- package/src/menu/helpers.js +36 -0
- package/src/menu/index.js +1 -0
- package/src/menu/menu.e2e.stories.js +5 -0
- package/src/menu/menu.js +96 -0
- package/src/menu/menu.prod.stories.js +90 -0
- package/src/menu/use-menu.js +100 -0
- package/src/menu-divider/index.js +1 -0
- package/src/menu-divider/menu-divider.js +32 -0
- package/src/menu-divider/menu-divider.prod.stories.js +30 -0
- package/src/menu-item/__tests__/menu-item.test.js +118 -0
- package/src/menu-item/features/accepts_href/index.js +11 -0
- package/src/menu-item/features/accepts_href.feature +5 -0
- package/src/menu-item/features/accepts_icon/index.js +10 -0
- package/src/menu-item/features/accepts_icon.feature +5 -0
- package/src/menu-item/features/accepts_label/index.js +10 -0
- package/src/menu-item/features/accepts_label.feature +5 -0
- package/src/menu-item/features/accepts_suffix/index.js +10 -0
- package/src/menu-item/features/accepts_suffix.feature +5 -0
- package/src/menu-item/features/accepts_target/index.js +11 -0
- package/src/menu-item/features/accepts_target.feature +5 -0
- package/src/menu-item/features/is_clickable/index.js +17 -0
- package/src/menu-item/features/is_clickable.feature +6 -0
- package/src/menu-item/index.js +1 -0
- package/src/menu-item/menu-item.e2e.stories.js +29 -0
- package/src/menu-item/menu-item.js +204 -0
- package/src/menu-item/menu-item.prod.stories.js +197 -0
- package/src/menu-item/menu-item.styles.js +133 -0
- package/src/menu-section-header/features/accepts_label/index.js +10 -0
- package/src/menu-section-header/features/accepts_label.feature +5 -0
- package/src/menu-section-header/index.js +1 -0
- package/src/menu-section-header/menu-section-header.e2e.stories.js +11 -0
- package/src/menu-section-header/menu-section-header.js +52 -0
- package/src/menu-section-header/menu-section-header.prod.stories.js +52 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { Input } from '@dhis2-ui/input'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { userEvent } from '@testing-library/user-event'
|
|
4
|
+
import { mount } from 'enzyme'
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import { MenuDivider } from '../../menu-divider/menu-divider.js'
|
|
7
|
+
import { MenuItem } from '../../menu-item/menu-item.js'
|
|
8
|
+
import { MenuSectionHeader } from '../../menu-section-header/menu-section-header.js'
|
|
9
|
+
import { Menu } from '../menu.js'
|
|
10
|
+
|
|
11
|
+
describe('Menu Component', () => {
|
|
12
|
+
const menuDataTest = 'data-test-menu'
|
|
13
|
+
const menuItemDataTest = 'data-test-menu-item'
|
|
14
|
+
const dividerDataTest = 'data-test-menu-divider'
|
|
15
|
+
|
|
16
|
+
it('Menu and menu items have aria attributes', () => {
|
|
17
|
+
const wrapper = mount(
|
|
18
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
19
|
+
<MenuSectionHeader label="Header" />
|
|
20
|
+
<MenuDivider dataTest={dividerDataTest} />
|
|
21
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item" />
|
|
22
|
+
</Menu>
|
|
23
|
+
)
|
|
24
|
+
const menuElement = wrapper.find({ 'data-test': menuDataTest })
|
|
25
|
+
const menuItem = wrapper.find({ 'data-test': menuItemDataTest })
|
|
26
|
+
const menuDivider = wrapper.find({ 'data-test': dividerDataTest })
|
|
27
|
+
|
|
28
|
+
expect(menuElement.prop('role')).toBe('menu')
|
|
29
|
+
|
|
30
|
+
expect(menuItem.childAt(0).props().role).toBe('menuitem')
|
|
31
|
+
expect(menuItem.childAt(0).prop('aria-label')).toBe('Menu item')
|
|
32
|
+
expect(menuDivider.find('[role="separator"]').exists()).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('Empty menu has role menu', () => {
|
|
36
|
+
const menuDataTest = 'data-test-menu'
|
|
37
|
+
const wrapper = mount(<Menu dataTest={menuDataTest} dense={false} />)
|
|
38
|
+
const menuElement = wrapper.find({ 'data-test': menuDataTest })
|
|
39
|
+
expect(menuElement.prop('role')).toBe('menu')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('can handle focus of first focusable element when tabbed to', async () => {
|
|
43
|
+
const { getByRole, getByText } = render(
|
|
44
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
45
|
+
<MenuSectionHeader label="Header" />
|
|
46
|
+
<MenuDivider dataTest={dividerDataTest} />
|
|
47
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 1" />
|
|
48
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 2" />
|
|
49
|
+
</Menu>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const menu = getByRole('menu')
|
|
53
|
+
const divider = getByRole('separator')
|
|
54
|
+
const header = getByText(/Header/i)
|
|
55
|
+
const menuItem1 = getByText(/Menu item 1/i)
|
|
56
|
+
const menuItem2 = getByText(/Menu item 2/i)
|
|
57
|
+
|
|
58
|
+
const user = userEvent.setup()
|
|
59
|
+
expect(menu).not.toHaveFocus()
|
|
60
|
+
await user.keyboard('{Tab}')
|
|
61
|
+
// check if LI parent node has focus or not
|
|
62
|
+
// headers and dividers do not receive focus
|
|
63
|
+
expect(header.parentNode.parentNode).not.toHaveFocus()
|
|
64
|
+
expect(divider.parentNode.parentNode).not.toHaveFocus()
|
|
65
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus()
|
|
66
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('can handle arrow down key navigation', async () => {
|
|
70
|
+
const { getByText } = render(
|
|
71
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
72
|
+
<MenuSectionHeader label="Header" />
|
|
73
|
+
<MenuDivider dataTest={dividerDataTest} />
|
|
74
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 1" />
|
|
75
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 2" />
|
|
76
|
+
</Menu>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const menuItem1 = getByText(/Menu item 1/i)
|
|
80
|
+
const menuItem2 = getByText(/Menu item 2/i)
|
|
81
|
+
|
|
82
|
+
const user = userEvent.setup()
|
|
83
|
+
expect(document.body).toHaveFocus()
|
|
84
|
+
|
|
85
|
+
// await fireEvent.focus(getByRole('menu'))
|
|
86
|
+
await user.keyboard('{Tab}')
|
|
87
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus()
|
|
88
|
+
// simulate arrowDown press
|
|
89
|
+
await user.keyboard('{ArrowDown}')
|
|
90
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus()
|
|
91
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus()
|
|
92
|
+
|
|
93
|
+
await user.keyboard('{ArrowDown}')
|
|
94
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus()
|
|
95
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('can handle arrow up key navigation', async () => {
|
|
99
|
+
const { getByText } = render(
|
|
100
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
101
|
+
<MenuSectionHeader label="Header" />
|
|
102
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 1" />
|
|
103
|
+
<MenuDivider dataTest={dividerDataTest} />
|
|
104
|
+
<MenuItem dataTest={menuItemDataTest} label="Menu item 2" />
|
|
105
|
+
</Menu>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const menuItem1 = getByText(/Menu item 1/i)
|
|
109
|
+
const menuItem2 = getByText(/Menu item 2/i)
|
|
110
|
+
|
|
111
|
+
const user = userEvent.setup()
|
|
112
|
+
await user.tab()
|
|
113
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus()
|
|
114
|
+
|
|
115
|
+
// simulate arrowUp press
|
|
116
|
+
await user.keyboard('{ArrowUp}')
|
|
117
|
+
expect(menuItem1.parentNode.parentNode).not.toHaveFocus()
|
|
118
|
+
expect(menuItem2.parentNode.parentNode).toHaveFocus()
|
|
119
|
+
|
|
120
|
+
await user.keyboard('{ArrowUp}')
|
|
121
|
+
expect(menuItem1.parentNode.parentNode).toHaveFocus()
|
|
122
|
+
expect(menuItem2.parentNode.parentNode).not.toHaveFocus()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('can handle space and enter key presses for clickable menu items', async () => {
|
|
126
|
+
const onClick = jest.fn()
|
|
127
|
+
const { getByText } = render(
|
|
128
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
129
|
+
<MenuItem
|
|
130
|
+
onClick={onClick}
|
|
131
|
+
value="myValue"
|
|
132
|
+
label="Click menu item"
|
|
133
|
+
/>
|
|
134
|
+
</Menu>
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const clickableItem = getByText(/Click menu item/i)
|
|
138
|
+
|
|
139
|
+
const user = userEvent.setup()
|
|
140
|
+
await user.tab()
|
|
141
|
+
expect(clickableItem.parentNode.parentNode).toHaveFocus()
|
|
142
|
+
|
|
143
|
+
await user.keyboard('[Space]')
|
|
144
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
145
|
+
|
|
146
|
+
await user.keyboard('{Enter}')
|
|
147
|
+
expect(onClick).toHaveBeenCalledTimes(2)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('can handle non MenuItem components', async () => {
|
|
151
|
+
const onClick = jest.fn()
|
|
152
|
+
const { getByText } = render(
|
|
153
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
154
|
+
<span role="menuitem" onClick={onClick}>
|
|
155
|
+
Span 1
|
|
156
|
+
</span>
|
|
157
|
+
<li tabIndex={-1}>
|
|
158
|
+
<a href="#" role="menuitem">
|
|
159
|
+
Link 2
|
|
160
|
+
</a>
|
|
161
|
+
</li>
|
|
162
|
+
<li>
|
|
163
|
+
<span onClick={onClick}>Span 2</span>
|
|
164
|
+
</li>
|
|
165
|
+
</Menu>
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const nonListMenuItem = getByText(/span 1/i)
|
|
169
|
+
const listMenuItem = getByText(/link 2/i)
|
|
170
|
+
const plainListItem = getByText(/span 2/i)
|
|
171
|
+
|
|
172
|
+
// all children must be list items
|
|
173
|
+
expect(nonListMenuItem.parentElement.nodeName).toBe('LI')
|
|
174
|
+
|
|
175
|
+
const user = userEvent.setup()
|
|
176
|
+
await user.tab()
|
|
177
|
+
expect(nonListMenuItem.parentElement).toHaveFocus()
|
|
178
|
+
expect(nonListMenuItem.parentElement.tabIndex).toBe(0)
|
|
179
|
+
|
|
180
|
+
expect(onClick).toHaveBeenCalledTimes(0)
|
|
181
|
+
await user.keyboard(' ')
|
|
182
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
183
|
+
|
|
184
|
+
await user.keyboard('{ArrowDown}')
|
|
185
|
+
expect(listMenuItem.parentElement).toHaveFocus()
|
|
186
|
+
|
|
187
|
+
await user.keyboard('{ArrowDown}')
|
|
188
|
+
expect(nonListMenuItem.parentElement).toHaveFocus()
|
|
189
|
+
// non menu items do not receive focus
|
|
190
|
+
expect(plainListItem.parentElement).not.toHaveFocus()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('does not hijack input change value if space entered [bug]', async () => {
|
|
194
|
+
const onChange = jest.fn()
|
|
195
|
+
const { getByPlaceholderText } = render(
|
|
196
|
+
<Menu dataTest={menuDataTest} dense={false}>
|
|
197
|
+
<MenuItem value="myValue" label="Click menu item" />
|
|
198
|
+
<Input onChange={onChange} placeholder="test"></Input>
|
|
199
|
+
</Menu>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const inputField = getByPlaceholderText('test')
|
|
203
|
+
const user = userEvent.setup()
|
|
204
|
+
await inputField.focus()
|
|
205
|
+
await user.type(inputField, 't')
|
|
206
|
+
await user.type(inputField, 'e')
|
|
207
|
+
await user.type(inputField, ' ')
|
|
208
|
+
await user.type(inputField, 'st')
|
|
209
|
+
|
|
210
|
+
expect(inputField.value).toBe('te st')
|
|
211
|
+
expect(onChange).toHaveBeenCalled()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Given, Then } from '@badeball/cypress-cucumber-preprocessor'
|
|
2
|
+
|
|
3
|
+
Given('a Menu with children is rendered', () => {
|
|
4
|
+
cy.visitStory('Menu', 'With children')
|
|
5
|
+
cy.get('[data-test="dhis2-uicore-menulist"]').should('be.visible')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
Then('the children are visible', () => {
|
|
9
|
+
cy.contains('I am a child').should('be.visible')
|
|
10
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
const role = node.getAttribute('role')
|
|
10
|
+
|
|
11
|
+
// for h1 - h6 headings since their heading role is not explicitly set
|
|
12
|
+
// style elements do not have roles
|
|
13
|
+
if (node.nodeName.startsWith('H') || node.nodeName === 'STYLE') {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (role) {
|
|
18
|
+
return isMenuItem(role)
|
|
19
|
+
} else {
|
|
20
|
+
console.warn('Missing: role attribute on the menu child')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const getFocusableItemsIndices = (elements) => {
|
|
25
|
+
const focusableIndices = []
|
|
26
|
+
elements.forEach((node, index) => {
|
|
27
|
+
if (isValidMenuItemNode(node)) {
|
|
28
|
+
focusableIndices.push(index)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
return focusableIndices
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const hasMenuItemRole = (component) => {
|
|
35
|
+
return isMenuItem(component?.props?.['role'])
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Menu } from './menu.js'
|
package/src/menu/menu.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React, { Children, cloneElement, isValidElement, useMemo } from 'react'
|
|
3
|
+
import { hasMenuItemRole } from './helpers.js'
|
|
4
|
+
import { useMenuNavigation } from './use-menu.js'
|
|
5
|
+
|
|
6
|
+
const Menu = ({
|
|
7
|
+
children,
|
|
8
|
+
className,
|
|
9
|
+
dataTest = 'dhis2-uicore-menulist',
|
|
10
|
+
dense,
|
|
11
|
+
}) => {
|
|
12
|
+
const { menuRef, focusedIndex } = useMenuNavigation(children)
|
|
13
|
+
|
|
14
|
+
const childrenToRender = useMemo(
|
|
15
|
+
() =>
|
|
16
|
+
Children.map(children, (child, index) => {
|
|
17
|
+
if (!isValidElement(child)) {
|
|
18
|
+
return child
|
|
19
|
+
}
|
|
20
|
+
const tabIndex = index === focusedIndex ? 0 : -1
|
|
21
|
+
|
|
22
|
+
const childProps = {
|
|
23
|
+
...child.props,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// this check is based on the type of child.
|
|
27
|
+
// if it is a native HTML element, like li, a, span, only apply its child props
|
|
28
|
+
// if it is a functional (React) component, it applies custom props, like dense, hideDivider, etc
|
|
29
|
+
if (typeof child.type === 'string') {
|
|
30
|
+
// if the native HTML element child is not li, then wrap it in an li tag
|
|
31
|
+
// apply the tabindex prop if a child has the menuitem role to make it focusable
|
|
32
|
+
if (child.type === 'li') {
|
|
33
|
+
return hasMenuItemRole(child.props.children[0])
|
|
34
|
+
? cloneElement(child, { ...childProps, tabIndex })
|
|
35
|
+
: cloneElement(child, childProps)
|
|
36
|
+
} else {
|
|
37
|
+
return (
|
|
38
|
+
<li
|
|
39
|
+
tabIndex={
|
|
40
|
+
hasMenuItemRole(child) ? tabIndex : null
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
{cloneElement(child, childProps)}
|
|
44
|
+
</li>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
childProps.dense =
|
|
49
|
+
typeof child.props.dense === 'boolean'
|
|
50
|
+
? child.props.dense
|
|
51
|
+
: dense
|
|
52
|
+
childProps.hideDivider =
|
|
53
|
+
typeof child.props.hideDivider !== 'boolean' &&
|
|
54
|
+
index === 0
|
|
55
|
+
? true
|
|
56
|
+
: child.props.hideDivider
|
|
57
|
+
return cloneElement(child, { ...childProps, tabIndex })
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
[children, dense, focusedIndex]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ul
|
|
65
|
+
className={className}
|
|
66
|
+
data-test={dataTest}
|
|
67
|
+
role="menu"
|
|
68
|
+
ref={menuRef}
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
>
|
|
71
|
+
{childrenToRender}
|
|
72
|
+
<style jsx>{`
|
|
73
|
+
ul {
|
|
74
|
+
display: block;
|
|
75
|
+
position: relative;
|
|
76
|
+
width: 100%;
|
|
77
|
+
margin: 0;
|
|
78
|
+
|
|
79
|
+
padding: 0;
|
|
80
|
+
user-select: none;
|
|
81
|
+
}
|
|
82
|
+
`}</style>
|
|
83
|
+
</ul>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Menu.propTypes = {
|
|
88
|
+
/** Typically `MenuItem`, `MenuDivider`, and `MenuSectionHeader` */
|
|
89
|
+
children: PropTypes.node,
|
|
90
|
+
className: PropTypes.string,
|
|
91
|
+
dataTest: PropTypes.string,
|
|
92
|
+
/** Applies `dense` property to all child components unless already specified */
|
|
93
|
+
dense: PropTypes.bool,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { Menu }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { MenuItem, MenuSectionHeader } from '../index.js'
|
|
3
|
+
import { Menu } from './index.js'
|
|
4
|
+
|
|
5
|
+
const description = `
|
|
6
|
+
Use menus to provide access to options and actions where space is limited and displaying all the options would be impractical. For example, providing access to a range of actions for every dashboard item displayed. Containing all those actions in menus keeps the page manageable.
|
|
7
|
+
|
|
8
|
+
The menu component is flexible in where it can be used and its contents can be flexible too. However, the most common use case is a menu containing menu items.
|
|
9
|
+
|
|
10
|
+
Make sure the menu item labels are short and easy to understand. One word is often enough to describe an action or option. Do not use sentences as labels. Some examples of good menu item labels:
|
|
11
|
+
|
|
12
|
+
- "Save"
|
|
13
|
+
- "Open as map"
|
|
14
|
+
- "Export PDF"
|
|
15
|
+
- "Duplicate"
|
|
16
|
+
|
|
17
|
+
Typical children are Menu Items, Menu Dividers, and Menu Section Headers.
|
|
18
|
+
|
|
19
|
+
\`\`\`js
|
|
20
|
+
import { Menu } from '@dhis2/ui'
|
|
21
|
+
\`\`\`
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
title: 'Menu',
|
|
26
|
+
component: Menu,
|
|
27
|
+
parameters: { docs: { description: { component: description } } },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Default = (args) => (
|
|
31
|
+
<Menu {...args}>
|
|
32
|
+
<MenuItem label="Menu item" />
|
|
33
|
+
<MenuItem label="Menu item" />
|
|
34
|
+
</Menu>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export const Dense = (args) => (
|
|
38
|
+
<Menu {...args}>
|
|
39
|
+
<MenuItem label="Menu item" />
|
|
40
|
+
<MenuItem label="Menu item" />
|
|
41
|
+
</Menu>
|
|
42
|
+
)
|
|
43
|
+
Dense.args = { dense: true }
|
|
44
|
+
Dense.parameters = {
|
|
45
|
+
docs: {
|
|
46
|
+
description: {
|
|
47
|
+
story: 'Menus are available in regular or dense sizes. Use dense menus in data-heavy applications used by users comfortable with technology. Use regular menus in apps that are less complex or have few controls.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const AutoHideFirstSectionHeaderDivider = (args) => (
|
|
53
|
+
<Menu {...args}>
|
|
54
|
+
<MenuSectionHeader label="First - no divider above" />
|
|
55
|
+
<MenuSectionHeader label="Second - with divider above" />
|
|
56
|
+
</Menu>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
export const SideBarMenu = (args) => (
|
|
60
|
+
<main
|
|
61
|
+
style={{
|
|
62
|
+
display: 'flex',
|
|
63
|
+
height: '100%',
|
|
64
|
+
border: '1px solid grey',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<aside style={{ width: 200, height: '100%', flexGrow: 0 }}>
|
|
68
|
+
<Menu {...args}>
|
|
69
|
+
<MenuItem label="Menu item" />
|
|
70
|
+
<MenuItem label="Menu item" />
|
|
71
|
+
</Menu>
|
|
72
|
+
</aside>
|
|
73
|
+
<section
|
|
74
|
+
style={{
|
|
75
|
+
backgroundColor: '#f3ffff',
|
|
76
|
+
flexGrow: 1,
|
|
77
|
+
padding: 20,
|
|
78
|
+
borderInlineStart: '1px solid grey',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
Main content
|
|
82
|
+
</section>
|
|
83
|
+
</main>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
export const SideBarMenuRTL = (args) => (
|
|
87
|
+
<div dir="rtl" style={{ height: '100%' }}>
|
|
88
|
+
<SideBarMenu {...args} />
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { getFocusableItemsIndices } from './helpers.js'
|
|
3
|
+
|
|
4
|
+
export const useMenuNavigation = (children) => {
|
|
5
|
+
const menuRef = useRef(null)
|
|
6
|
+
const [focusableItemsIndices, setFocusableItemsIndices] = useState(null)
|
|
7
|
+
const [activeItemIndex, setActiveItemIndex] = useState(-1)
|
|
8
|
+
|
|
9
|
+
// Initializes the indices for focusable items
|
|
10
|
+
// focusable items have the role of menuitem || menuitemcheckbox || menuitemradio
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (menuRef) {
|
|
13
|
+
const menuItems = Array.from(menuRef.current.children)
|
|
14
|
+
const itemsIndices = getFocusableItemsIndices(menuItems)
|
|
15
|
+
setFocusableItemsIndices(itemsIndices)
|
|
16
|
+
}
|
|
17
|
+
}, [children])
|
|
18
|
+
|
|
19
|
+
// Focus the active menu child
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (menuRef) {
|
|
22
|
+
if (focusableItemsIndices?.length && activeItemIndex > -1) {
|
|
23
|
+
const currentIndex = focusableItemsIndices[activeItemIndex]
|
|
24
|
+
menuRef.current.children[currentIndex].focus()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}, [activeItemIndex, focusableItemsIndices])
|
|
28
|
+
|
|
29
|
+
// Navigate through focusable children using arrow keys
|
|
30
|
+
// Trigger actionable items
|
|
31
|
+
const handleKeyDown = useCallback(
|
|
32
|
+
(event) => {
|
|
33
|
+
const totalFocusablePositions = focusableItemsIndices?.length
|
|
34
|
+
if (totalFocusablePositions) {
|
|
35
|
+
const lastIndex = totalFocusablePositions - 1
|
|
36
|
+
|
|
37
|
+
switch (event.key) {
|
|
38
|
+
case 'ArrowUp':
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
setActiveItemIndex(
|
|
41
|
+
activeItemIndex > 0
|
|
42
|
+
? activeItemIndex - 1
|
|
43
|
+
: lastIndex
|
|
44
|
+
)
|
|
45
|
+
break
|
|
46
|
+
case 'ArrowDown':
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
setActiveItemIndex(
|
|
49
|
+
activeItemIndex >= lastIndex
|
|
50
|
+
? 0
|
|
51
|
+
: activeItemIndex + 1
|
|
52
|
+
)
|
|
53
|
+
break
|
|
54
|
+
case 'Enter':
|
|
55
|
+
case ' ':
|
|
56
|
+
if (event.target.nodeName === 'LI') {
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
event.target.children?.[0]?.click()
|
|
59
|
+
}
|
|
60
|
+
break
|
|
61
|
+
default:
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[activeItemIndex, focusableItemsIndices?.length]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Event listeners for menu focus and key handling
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!menuRef) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const menu = menuRef.current
|
|
76
|
+
|
|
77
|
+
// Focus the first menu item when the menu receives focus
|
|
78
|
+
const handleFocus = (event) => {
|
|
79
|
+
if (event.target === menuRef.current) {
|
|
80
|
+
const firstItemIndex = focusableItemsIndices?.[0]
|
|
81
|
+
firstItemIndex &&
|
|
82
|
+
menuRef.current.children[firstItemIndex].focus()
|
|
83
|
+
setActiveItemIndex(0)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
menu.addEventListener('focus', handleFocus)
|
|
88
|
+
menu.addEventListener('keydown', handleKeyDown)
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
menu.removeEventListener('focus', handleFocus)
|
|
92
|
+
menu.removeEventListener('keydown', handleKeyDown)
|
|
93
|
+
}
|
|
94
|
+
}, [activeItemIndex, focusableItemsIndices, handleKeyDown])
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
menuRef,
|
|
98
|
+
focusedIndex: focusableItemsIndices?.[activeItemIndex],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MenuDivider } from './menu-divider.js'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { colors } from '@dhis2/ui-constants'
|
|
2
|
+
import { Divider } from '@dhis2-ui/divider'
|
|
3
|
+
import PropTypes from 'prop-types'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
|
|
6
|
+
const MenuDivider = ({
|
|
7
|
+
className,
|
|
8
|
+
dataTest = 'dhis2-uicore-menudivider',
|
|
9
|
+
dense,
|
|
10
|
+
}) => (
|
|
11
|
+
<li className={className} data-test={dataTest}>
|
|
12
|
+
<Divider dense={dense} />
|
|
13
|
+
|
|
14
|
+
<style jsx>{`
|
|
15
|
+
li {
|
|
16
|
+
list-style: none;
|
|
17
|
+
background-color: ${colors.white};
|
|
18
|
+
user-select: none;
|
|
19
|
+
padding: 0;
|
|
20
|
+
line-height: 0;
|
|
21
|
+
}
|
|
22
|
+
`}</style>
|
|
23
|
+
</li>
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MenuDivider.propTypes = {
|
|
27
|
+
className: PropTypes.string,
|
|
28
|
+
dataTest: PropTypes.string,
|
|
29
|
+
dense: PropTypes.bool,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { MenuDivider }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Menu, MenuItem } from '../index.js'
|
|
3
|
+
import { MenuDivider } from './menu-divider.js'
|
|
4
|
+
|
|
5
|
+
const description = `
|
|
6
|
+
Items in a menu can be split into separate sections by using dividers. Group relevant menu items together to help the user understand the options quickly. A divider can be used alone. If using a MenuSectionHeader, a divider will be automatically included. Try not to group single menu items together. An exception to this is a critical destructive menu item, like 'Delete', which can be separated from other menu items.
|
|
7
|
+
|
|
8
|
+
\`\`\`js
|
|
9
|
+
import { MenuDivider } from '@dhis2/ui'
|
|
10
|
+
\`\`\`
|
|
11
|
+
`
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
title: 'Menu Divider',
|
|
15
|
+
component: MenuDivider,
|
|
16
|
+
parameters: { docs: { description: { component: description } } },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Template = (args) => (
|
|
20
|
+
<Menu>
|
|
21
|
+
<MenuItem label="Item above divider" />
|
|
22
|
+
<MenuDivider {...args} />
|
|
23
|
+
<MenuItem label="Item below divider" />
|
|
24
|
+
</Menu>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const Default = Template.bind({})
|
|
28
|
+
|
|
29
|
+
export const Dense = Template.bind({})
|
|
30
|
+
Dense.args = { dense: true }
|