Skip to content

Commit 96a151a

Browse files
Merge DropdownMenu2 into ActionMenu2 (#1848)
* merge docs * merge stories/examples * merge stories/fixtures * move itemRole to item and delete dropdonwmenu * add test for single selection * Add tests for additional use cases * delete outdated snapshot * add changeset * fix group roles based on latest discussion * Revert "fix group roles based on latest discussion" This reverts commit 1e90bda. * Update docs/content/drafts/ActionMenu2.mdx Co-authored-by: Cole Bemis <[email protected]> * use merged ActionMenu instead of Dropdown in Overlay * bring back DropdownMenu2 part 1/2 * bring back DropdownMenu2 part 2/2 Co-authored-by: Cole Bemis <[email protected]>
1 parent b22a795 commit 96a151a

File tree

15 files changed

+547
-174
lines changed

15 files changed

+547
-174
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
Merges drafts/DropdownMenu2 into drafts/ActionMenu2

docs/content/drafts/ActionMenu2.mdx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,44 @@ You can choose to have a different _anchor_ for the Menu dependending on the app
175175
</ActionMenu>
176176
```
177177

178+
### With selection
179+
180+
Use `selectionVariant` on `ActionList` to create a menu with single or multiple selection.
181+
182+
```javascript live noinline drafts
183+
const fieldTypes = [
184+
{icon: TypographyIcon, name: 'Text'},
185+
{icon: NumberIcon, name: 'Number'},
186+
{icon: CalendarIcon, name: 'Date'},
187+
{icon: SingleSelectIcon, name: 'Single select'},
188+
{icon: IterationsIcon, name: 'Iteration'}
189+
]
190+
191+
const Example = () => {
192+
const [selectedIndex, setSelectedIndex] = React.useState(1)
193+
const selectedType = fieldTypes[selectedIndex]
194+
195+
return (
196+
<ActionMenu>
197+
<ActionMenu.Button aria-label="Select field type" leadingIcon={selectedType.icon}>
198+
{selectedType.name}
199+
</ActionMenu.Button>
200+
<ActionMenu.Overlay width="medium">
201+
<ActionList selectionVariant="single">
202+
{fieldTypes.map((type, index) => (
203+
<ActionList.Item key={index} selected={index === selectedIndex} onSelect={() => setSelectedIndex(index)}>
204+
<type.icon /> {type.name}
205+
</ActionList.Item>
206+
))}
207+
</ActionList>
208+
</ActionMenu.Overlay>
209+
</ActionMenu>
210+
)
211+
}
212+
213+
render(<Example />)
214+
```
215+
178216
### With External Anchor
179217

180218
To create an anchor outside of the menu, you need to switch to controlled mode for the menu and pass it as `anchorRef` to `ActionMenu`. Make sure you add `aria-expanded` and `aria-haspopup` to the external anchor:
@@ -241,12 +279,6 @@ render(
241279
)
242280
```
243281

244-
<Note variant="warning">
245-
246-
Use `ActionMenu` to choose an action from a list. If you’re looking for single or multiple selection, use [DropdownMenu](/DropdownMenu) or [SelectPanel](/SelectPanel) instead.
247-
248-
</Note>
249-
250282
## Props / API reference
251283

252284
### ActionMenu
@@ -305,6 +337,5 @@ Use `ActionMenu` to choose an action from a list. If you’re looking for single
305337
## Related components
306338

307339
- [ActionList](/drafts/ActionList2)
308-
- [DropdownMenu](/DropdownMenu)
309340
- [SelectPanel](/SelectPanel)
310341
- [Button](/drafts/Button2)

src/ActionList2/ActionListContainerContext.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import React from 'react'
55
type ContextProps = {
66
container?: string
77
listRole?: string
8-
itemRole?: string
9-
selectionVariant?: 'single' | 'multiple'
8+
selectionVariant?: 'single' | 'multiple' // TODO: Remove after DropdownMenu2 deprecation
109
selectionAttribute?: 'aria-selected' | 'aria-checked'
1110
listLabelledBy?: string
1211
// This can be any function, we don't know anything about the arguments

src/ActionList2/Item.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import Box, {BoxProps} from '../Box'
77
import sx, {SxProp, merge} from '../sx'
88
import createSlots from '../utils/create-slots'
99
import {AriaRole} from '../utils/types'
10-
import {ListContext} from './List'
10+
import {ListContext, ListProps} from './List'
11+
import {GroupContext, GroupProps} from './Group'
1112
import {ActionListContainerContext} from './ActionListContainerContext'
1213
import {Selection} from './Selection'
1314

@@ -101,8 +102,21 @@ export const Item = React.forwardRef<HTMLLIElement, ItemProps>(
101102
},
102103
forwardedRef
103104
): JSX.Element => {
104-
const {variant: listVariant, showDividers} = React.useContext(ListContext)
105-
const {itemRole, afterSelect, selectionAttribute = 'aria-selected'} = React.useContext(ActionListContainerContext)
105+
const {variant: listVariant, showDividers, selectionVariant: listSelectionVariant} = React.useContext(ListContext)
106+
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
107+
const {container, afterSelect, selectionAttribute = 'aria-selected'} = React.useContext(ActionListContainerContext)
108+
109+
let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant']
110+
if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant
111+
else selectionVariant = listSelectionVariant
112+
113+
/** Infer item role based on the container */
114+
let itemRole: ItemProps['role']
115+
if (container === 'ActionMenu' || container === 'DropdownMenu') {
116+
if (selectionVariant === 'single') itemRole = 'menuitemradio'
117+
else if (selectionVariant === 'multiple') itemRole = 'menuitemcheckbox'
118+
else itemRole = 'menuitem'
119+
}
106120

107121
const {theme} = useTheme()
108122

src/ActionList2/List.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export const List = React.forwardRef<HTMLUListElement, ListProps>(
4141
}
4242

4343
/** if list is inside a Menu, it will get a role from the Menu */
44-
const {listRole, listLabelledBy} = React.useContext(ActionListContainerContext)
44+
const {
45+
listRole,
46+
listLabelledBy,
47+
selectionVariant: containerSelectionVariant // TODO: Remove after DropdownMenu2 deprecation
48+
} = React.useContext(ActionListContainerContext)
4549

4650
return (
4751
<ListBox
@@ -51,7 +55,11 @@ export const List = React.forwardRef<HTMLUListElement, ListProps>(
5155
{...props}
5256
ref={forwardedRef}
5357
>
54-
<ListContext.Provider value={{variant, selectionVariant, showDividers}}>{props.children}</ListContext.Provider>
58+
<ListContext.Provider
59+
value={{variant, selectionVariant: selectionVariant || containerSelectionVariant, showDividers}}
60+
>
61+
{props.children}
62+
</ListContext.Provider>
5563
</ListBox>
5664
)
5765
}

src/ActionList2/Selection.tsx

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,31 @@
11
import React from 'react'
22
import {CheckIcon} from '@primer/octicons-react'
3-
import {ListContext} from './List'
4-
import {GroupContext} from './Group'
5-
import {ActionListContainerContext} from './ActionListContainerContext'
3+
import {ListContext, ListProps} from './List'
4+
import {GroupContext, GroupProps} from './Group'
65
import {ItemProps} from './Item'
76
import {LeadingVisualContainer} from './Visuals'
87

98
type SelectionProps = Pick<ItemProps, 'selected'>
109
export const Selection: React.FC<SelectionProps> = ({selected}) => {
1110
const {selectionVariant: listSelectionVariant} = React.useContext(ListContext)
1211
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
13-
const {container, selectionVariant: menuSelectionVariant} = React.useContext(ActionListContainerContext)
1412

1513
/** selectionVariant in Group can override the selectionVariant in List root */
16-
/** fallback to selectionVariant from container menu if any (ActionMenu, DropdownMenu, SelectPanel ) */
17-
let selectionVariant
14+
/** fallback to selectionVariant from container menu if any (ActionMenu, SelectPanel ) */
15+
let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant']
1816
if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant
19-
else selectionVariant = listSelectionVariant || menuSelectionVariant
17+
else selectionVariant = listSelectionVariant
2018

21-
// if selectionVariant is not set on List, don't show selection
2219
if (!selectionVariant) {
23-
// to avoid confusion, fail loudly instead of silently ignoring
24-
if (selected)
20+
// if selectionVariant is not set on List, but Item is selected
21+
// fail loudly instead of silently ignoring
22+
if (selected) {
2523
throw new Error(
2624
'For Item to be selected, ActionList or ActionList.Group needs to have a selectionVariant defined'
2725
)
28-
return null
29-
}
30-
31-
if (container === 'ActionMenu') {
32-
throw new Error(
33-
'ActionList cannot have a selectionVariant inside ActionMenu, please use DropdownMenu or SelectPanel instead. More information: https://primer.style/design/components/action-list#application'
34-
)
35-
}
36-
37-
if (container === 'DropdownMenu' && selectionVariant === 'multiple') {
38-
throw new Error(
39-
'selectionVariant multiple cannot be used in DropdownMenu, please use SelectPanel instead. More information: https://primer.style/design/components/action-list#application'
40-
)
26+
} else {
27+
return null
28+
}
4129
}
4230

4331
if (selectionVariant === 'single') {

src/ActionMenu2.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, ...overlayProps}) => {
113113
value={{
114114
container: 'ActionMenu',
115115
listRole: 'menu',
116-
itemRole: 'menuitem',
117116
listLabelledBy: anchorId,
117+
selectionAttribute: 'aria-checked', // Should this be here?
118118
afterSelect: onClose
119119
}}
120120
>

src/DropdownMenu2.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, ...overlayProps}) => {
117117
value={{
118118
container: 'DropdownMenu',
119119
listRole: 'menu',
120-
itemRole: 'menuitemradio',
121120
listLabelledBy: anchorId,
122121
selectionVariant: 'single',
123122
selectionAttribute: 'aria-checked',

src/__tests__/ActionMenu2.test.tsx

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {ActionMenu} from '../ActionMenu2'
77
import {ActionList} from '../ActionList2'
88
import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing'
99
import {BaseStyles, ThemeProvider, SSRProvider} from '..'
10+
import {SingleSelection, MixedSelection} from '../../src/stories/ActionMenu2/examples.stories'
1011
import '@testing-library/jest-dom'
1112
expect.extend(toHaveNoViolations)
1213

13-
function SimpleActionMenu(): JSX.Element {
14+
function Example(): JSX.Element {
1415
return (
1516
<ThemeProvider theme={theme}>
1617
<SSRProvider>
@@ -39,7 +40,7 @@ describe('ActionMenu', () => {
3940
behavesAsComponent({
4041
Component: ActionList,
4142
options: {skipAs: true, skipSx: true},
42-
toRender: () => <SimpleActionMenu />
43+
toRender: () => <Example />
4344
})
4445

4546
checkExports('ActionMenu2', {
@@ -48,15 +49,15 @@ describe('ActionMenu', () => {
4849
})
4950

5051
it('should open Menu on MenuButton click', async () => {
51-
const component = HTMLRender(<SimpleActionMenu />)
52+
const component = HTMLRender(<Example />)
5253
const button = component.getByText('Toggle Menu')
5354
fireEvent.click(button)
5455
expect(component.getByRole('menu')).toBeInTheDocument()
5556
cleanup()
5657
})
5758

5859
it('should open Menu on MenuButton keypress', async () => {
59-
const component = HTMLRender(<SimpleActionMenu />)
60+
const component = HTMLRender(<Example />)
6061
const button = component.getByText('Toggle Menu')
6162

6263
// We pass keycode here to navigate a implementation detail in react-testing-library
@@ -67,7 +68,7 @@ describe('ActionMenu', () => {
6768
})
6869

6970
it('should close Menu on selecting an action with click', async () => {
70-
const component = HTMLRender(<SimpleActionMenu />)
71+
const component = HTMLRender(<Example />)
7172
const button = component.getByText('Toggle Menu')
7273

7374
fireEvent.click(button)
@@ -79,7 +80,7 @@ describe('ActionMenu', () => {
7980
})
8081

8182
it('should close Menu on selecting an action with Enter', async () => {
82-
const component = HTMLRender(<SimpleActionMenu />)
83+
const component = HTMLRender(<Example />)
8384
const button = component.getByText('Toggle Menu')
8485

8586
fireEvent.click(button)
@@ -91,7 +92,7 @@ describe('ActionMenu', () => {
9192
})
9293

9394
it('should not close Menu if event is prevented', async () => {
94-
const component = HTMLRender(<SimpleActionMenu />)
95+
const component = HTMLRender(<Example />)
9596
const button = component.getByText('Toggle Menu')
9697

9798
fireEvent.click(button)
@@ -103,38 +104,42 @@ describe('ActionMenu', () => {
103104
cleanup()
104105
})
105106

106-
it('should throw when selectionVariant is provided to ActionList within ActionMenu', async () => {
107-
// we expect console.error to be called, so we suppress that in the test
108-
const mockError = jest.spyOn(console, 'error').mockImplementation(() => jest.fn())
109-
110-
expect(() => {
111-
const component = HTMLRender(
112-
<ThemeProvider theme={theme}>
113-
<SSRProvider>
114-
<BaseStyles>
115-
<ActionMenu>
116-
<ActionMenu.Button>Toggle Menu</ActionMenu.Button>
117-
<ActionMenu.Overlay>
118-
<ActionList selectionVariant="single">
119-
<ActionList.Item selected={true}>Primer React</ActionList.Item>
120-
</ActionList>
121-
</ActionMenu.Overlay>
122-
</ActionMenu>
123-
</BaseStyles>
124-
</SSRProvider>
125-
</ThemeProvider>
126-
)
127-
128-
const button = component.getByText('Toggle Menu')
129-
fireEvent.click(button)
130-
}).toThrow('ActionList cannot have a selectionVariant inside ActionMenu')
107+
it('should be able to select an Item with selectionVariant', async () => {
108+
const component = HTMLRender(
109+
<ThemeProvider theme={theme}>
110+
<SingleSelection />
111+
</ThemeProvider>
112+
)
113+
const button = component.getByLabelText('Select field type')
114+
fireEvent.click(button)
115+
116+
// select first item by role, that would close the menu
117+
fireEvent.click(component.getAllByRole('menuitemradio')[0])
118+
expect(component.queryByRole('menu')).not.toBeInTheDocument()
119+
120+
// open menu again and check if the first option is checked
121+
fireEvent.click(button)
122+
expect(component.getAllByRole('menuitemradio')[0]).toHaveAttribute('aria-checked', 'true')
123+
cleanup()
124+
})
125+
126+
it('should assign the right roles with groups & mixed selectionVariant', async () => {
127+
const component = HTMLRender(
128+
<ThemeProvider theme={theme}>
129+
<MixedSelection />
130+
</ThemeProvider>
131+
)
132+
const button = component.getByLabelText('Select field type to group by')
133+
fireEvent.click(button)
134+
135+
expect(component.getByLabelText('Status')).toHaveAttribute('role', 'menuitemradio')
136+
expect(component.getByLabelText('Clear Group by')).toHaveAttribute('role', 'menuitem')
131137

132138
cleanup()
133-
mockError.mockRestore()
134139
})
135140

136141
it('should have no axe violations', async () => {
137-
const {container} = HTMLRender(<SimpleActionMenu />)
142+
const {container} = HTMLRender(<Example />)
138143
const results = await axe(container)
139144
expect(results).toHaveNoViolations()
140145
cleanup()

src/__tests__/DropdownMenu2.test.tsx

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -96,36 +96,6 @@ describe('DropdownMenu', () => {
9696
cleanup()
9797
})
9898

99-
it('should throw when selectionVariant=multiple is provided to ActionList within DropdownMenu', async () => {
100-
// we expect console.error to be called, so we suppress that in the test
101-
const mockError = jest.spyOn(console, 'error').mockImplementation(() => jest.fn())
102-
103-
expect(() => {
104-
const component = HTMLRender(
105-
<ThemeProvider theme={theme}>
106-
<SSRProvider>
107-
<BaseStyles>
108-
<DropdownMenu>
109-
<DropdownMenu.Button>Select a field</DropdownMenu.Button>
110-
<DropdownMenu.Overlay>
111-
<ActionList selectionVariant="multiple">
112-
<ActionList.Item selected={true}>Primer React</ActionList.Item>
113-
</ActionList>
114-
</DropdownMenu.Overlay>
115-
</DropdownMenu>
116-
</BaseStyles>
117-
</SSRProvider>
118-
</ThemeProvider>
119-
)
120-
121-
const button = component.getByText('Select a field')
122-
fireEvent.click(button)
123-
}).toThrow('selectionVariant multiple cannot be used in DropdownMenu')
124-
125-
cleanup()
126-
mockError.mockRestore()
127-
})
128-
12999
checkStoriesForAxeViolations('DropdownMenu2/fixtures')
130100
checkStoriesForAxeViolations('DropdownMenu2/examples')
131101
})

0 commit comments

Comments
 (0)