Skip to content

Commit 5eb3f06

Browse files
committed
feat(menu): adds destructive variant to menu items
For both Menu and ContextMenu Extracts common styles to utilities fix #250
1 parent 713dd07 commit 5eb3f06

File tree

7 files changed

+339
-263
lines changed

7 files changed

+339
-263
lines changed

src/components/ContextMenu/ContextMenu.stories.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,42 @@ export const Nested: Story = () => {
215215
</ContextMenu>
216216
)
217217
}
218+
219+
/** use the `destructive flag to indicate the action triggered it destructive */
220+
export const Destructive: Story = (args) => {
221+
const [checked, { toggle }] = useBoolean(true)
222+
const [color, setColor] = React.useState('blue')
223+
return (
224+
<ContextMenu>
225+
<ContextMenuTrigger>
226+
<ClickTarget />
227+
</ContextMenuTrigger>
228+
<ContextMenuContent>
229+
<ContextMenuItem>
230+
Open <ContextMenuItemShortcut>⌘+O</ContextMenuItemShortcut>
231+
</ContextMenuItem>
232+
<ContextMenuItem destructive>
233+
Delete <ContextMenuItemShortcut>⌘+T</ContextMenuItemShortcut>
234+
</ContextMenuItem>
235+
<ContextMenuSeparator />
236+
<ContextMenuLabel>Actions</ContextMenuLabel>
237+
<ContextMenuItem destructive onSelect={action('cut')}>
238+
Cut
239+
</ContextMenuItem>
240+
<ContextMenuItem onSelect={action('copy')}>Copy</ContextMenuItem>
241+
<ContextMenuCheckboxItem checked={checked} onSelect={toggle}>
242+
Paste
243+
</ContextMenuCheckboxItem>
244+
<ContextMenuSeparator />
245+
<ContextMenuLabel>Colours</ContextMenuLabel>
246+
<ContextMenuRadioGroup value={color} onValueChange={setColor}>
247+
<ContextMenuRadioItem destructive value="red">
248+
Red
249+
</ContextMenuRadioItem>
250+
<ContextMenuRadioItem value="blue">Blue</ContextMenuRadioItem>
251+
<ContextMenuRadioItem value="green">Green</ContextMenuRadioItem>
252+
</ContextMenuRadioGroup>
253+
</ContextMenuContent>
254+
</ContextMenu>
255+
)
256+
}

src/components/ContextMenu/ContextMenu.test.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import { composeStories } from '@storybook/testing-react'
12
import React from 'react'
23
import {
4+
act,
5+
fireEvent,
36
renderDark,
47
renderLight,
58
screen,
69
userEvent,
7-
fireEvent,
8-
act,
910
} from 'test-utils'
10-
import { Default, Nested } from './ContextMenu.stories'
11+
import * as stories from './ContextMenu.stories'
12+
13+
const { Default, Nested, Destructive } = composeStories(stories)
1114

1215
it('renders light without error', () => {
1316
const { asFragment } = renderLight(<Default />)
@@ -49,3 +52,16 @@ it('can render nested menus', () => {
4952
userEvent.click(screen.getByRole('menuitem', { name: /developer/i }))
5053
expect(screen.getByRole('menuitem', { name: /test/i })).toBeInTheDocument()
5154
})
55+
56+
it('can render destructive menus', () => {
57+
renderDark(<Destructive />)
58+
expect(
59+
screen.queryByRole('menuitem', { name: /delete/i })
60+
).not.toBeInTheDocument()
61+
act(() => {
62+
fireEvent.contextMenu(screen.getByText('Right click anywhere'))
63+
})
64+
expect(
65+
screen.queryByRole('menuitem', { name: /delete/i })
66+
).toBeInTheDocument()
67+
})

src/components/ContextMenu/ContextMenu.tsx

Lines changed: 60 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -12,80 +12,57 @@ import {
1212
Trigger,
1313
TriggerItem,
1414
} from '@radix-ui/react-context-menu'
15-
import React, { ComponentProps, ElementRef, FC, forwardRef } from 'react'
16-
import type { CSSProps } from '../../stitches.config'
15+
import React, { ComponentProps, ElementRef, forwardRef } from 'react'
16+
import type { CSSProps, VariantProps } from '../../stitches.config'
1717
import { styled } from '../../stitches.config'
18-
import { Check, ChevronRight } from '../Icons'
19-
20-
const StyledContent = styled(Content, {
21-
minWidth: '$10',
22-
backgroundColor: '$background',
23-
borderRadius: '$default',
24-
padding: '$1',
25-
boxShadow: '$1',
26-
})
27-
28-
const itemStyles = {
29-
fontSize: '$-1',
30-
padding: '$1 $3',
31-
borderRadius: '$default',
32-
cursor: 'default',
33-
transition: 'all 50ms',
34-
display: 'flex',
35-
alignItems: 'center',
36-
justifyContent: 'space-between',
37-
38-
'&:focus': {
39-
outline: 'none',
40-
backgroundColor: '$selection',
41-
color: '$text',
42-
},
43-
44-
'&[data-disabled]': {
45-
color: '$grey9',
46-
},
47-
}
48-
49-
const StyledItem = styled(Item, itemStyles)
18+
import {
19+
checkboxItemStyles,
20+
contentStyles,
21+
groupStyles,
22+
itemIndicatorStyles,
23+
itemShortcutStyles,
24+
itemStyles,
25+
labelStyles,
26+
separatorStyles,
27+
StyledCheckIndicator,
28+
StyledTriggerItemIndicator,
29+
triggerItemStyles,
30+
} from '../../utils/menuStyles'
31+
import { paperStyles } from '../Paper'
5032

51-
const StyledSeparator = styled(Separator, {
52-
height: 1,
53-
backgroundColor: '$grey8',
33+
/**
34+
* ContextMenu component
35+
*
36+
* Displays a pop up menu when right clicking the `ContextMenuTrigger`.
37+
*
38+
* The content should be wrapped in a `ContextMenuContent` and should be made up of the other `ContextMenuXxxx` components.
39+
*
40+
* Based on [Radix Context Menu](https://radix-ui.com/primitives/docs/components/context-menu).
41+
*/
42+
export const ContextMenu = Root
43+
export const ContextMenuTrigger = Trigger
44+
export const ContextMenuItem = styled(Item, itemStyles)
45+
export const ContextMenuItemShortcut = styled('span', itemShortcutStyles)
46+
export const ContextMenuSeparator = styled(Separator, separatorStyles)
47+
export const ContextMenuLabel = styled(Label, labelStyles)
48+
export const ContextMenuItemGroup = styled(Group, groupStyles(ContextMenuItem))
49+
export const ContextMenuRadioGroup = RadioGroup
5450

55-
variants: {
56-
orientation: {
57-
horizontal: {
58-
height: 1,
59-
margin: '$1 0',
60-
},
61-
vertical: {
62-
width: 1,
63-
height: 'auto',
64-
margin: '0 $1',
65-
flex: '1 1 100%',
66-
},
67-
},
68-
},
69-
defaultVariants: {
70-
orientation: 'horizontal',
71-
},
72-
})
51+
export const ContextMenuContent = styled(Content, paperStyles, contentStyles)
7352

74-
const StyledLabel = styled(Label, {
75-
color: '$grey10',
76-
fontSize: '$-1',
77-
padding: '$1 $3',
78-
cursor: 'default',
79-
})
53+
const StyledItemIndicator = styled(ItemIndicator, itemIndicatorStyles)
54+
const StyledCheckboxItem = styled(CheckboxItem, itemStyles, checkboxItemStyles)
55+
const StyledRadioItem = styled(RadioItem, itemStyles, checkboxItemStyles)
8056

81-
const StyledContextMenuTriggerItem = styled(TriggerItem, {
82-
...itemStyles,
83-
'&[data-state="open"]': {
84-
background: '$selection',
85-
},
86-
})
57+
const StyledContextMenuTriggerItem = styled(
58+
TriggerItem,
59+
itemStyles,
60+
triggerItemStyles
61+
)
8762

88-
type ContextMenuTriggerItemProps = ComponentProps<typeof TriggerItem> & CSSProps
63+
type ContextMenuTriggerItemProps = ComponentProps<
64+
typeof StyledContextMenuTriggerItem
65+
>
8966

9067
export const ContextMenuTriggerItem = forwardRef<
9168
ElementRef<typeof StyledContextMenuTriggerItem>,
@@ -94,93 +71,47 @@ export const ContextMenuTriggerItem = forwardRef<
9471
return (
9572
<StyledContextMenuTriggerItem {...props} ref={forwardedRef}>
9673
{children}
97-
<ChevronRight css={{ size: '$4' }} />
74+
<StyledTriggerItemIndicator />
9875
</StyledContextMenuTriggerItem>
9976
)
10077
})
10178
ContextMenuTriggerItem.toString = () =>
10279
`.${StyledContextMenuTriggerItem.className}`
10380

104-
export const ContextMenuItemGroup = styled(Group, {
105-
display: 'flex',
106-
marginLeft: '$3',
107-
marginRight: '$1',
108-
109-
[`& ${StyledItem}`]: {
110-
paddingLeft: '$2',
111-
},
112-
})
113-
114-
export const ContextMenuItemShortcut = styled('span', {
115-
fontFamily: '$monospace',
116-
lineHeight: '$body',
117-
color: '$textSecondary',
118-
marginLeft: '$3',
119-
})
120-
121-
/**
122-
* The `ContextMenu` displays a pop up menu when right clicking the `ContextMenuTrigger`.
123-
*/
124-
export const ContextMenu = Root
125-
export const ContextMenuTrigger = Trigger
126-
export const ContextMenuItem: FC<ComponentProps<typeof Item>> = StyledItem
127-
export const ContextMenuContent: FC<
128-
ComponentProps<typeof Content>
129-
> = StyledContent
130-
export const ContextMenuSeparator: FC<
131-
ComponentProps<typeof StyledSeparator>
132-
> = StyledSeparator
133-
export const ContextMenuLabel: FC<ComponentProps<typeof Label>> = StyledLabel
134-
135-
const StyledItemIndicator = styled(ItemIndicator, {
136-
position: 'absolute',
137-
left: '$2',
138-
})
139-
140-
const StyledContextMenuCheckboxItem = styled(CheckboxItem, {
141-
...itemStyles,
142-
padding: '$1 $2 $1 $5',
143-
})
144-
14581
type ContextMenuCheckboxItemProps = ComponentProps<typeof CheckboxItem> &
14682
CSSProps
14783

14884
export const ContextMenuCheckboxItem = forwardRef<
149-
ElementRef<typeof StyledContextMenuCheckboxItem>,
85+
ElementRef<typeof StyledCheckboxItem>,
15086
ContextMenuCheckboxItemProps
15187
>(({ children, ...props }, forwardedRef) => {
15288
return (
153-
<StyledContextMenuCheckboxItem {...props} ref={forwardedRef}>
89+
<StyledCheckboxItem {...props} ref={forwardedRef}>
15490
<StyledItemIndicator>
155-
<Check css={{ height: 16, width: 16 }} />
91+
<StyledCheckIndicator />
15692
</StyledItemIndicator>
15793
{children}
158-
</StyledContextMenuCheckboxItem>
94+
</StyledCheckboxItem>
15995
)
16096
})
161-
ContextMenuCheckboxItem.toString = () =>
162-
`.${StyledContextMenuCheckboxItem.className}`
163-
164-
const StyledContextMenuRadioItem = styled(RadioItem, {
165-
...itemStyles,
166-
padding: '$1 $2 $1 $5',
167-
})
97+
ContextMenuCheckboxItem.toString = () => `.${StyledCheckboxItem.className}`
16898

169-
type ContextMenuRadioItemProps = ComponentProps<typeof RadioItem> & CSSProps
99+
type RadioItemVariants = VariantProps<typeof StyledRadioItem>
100+
type ContextMenuRadioItemProps = ComponentProps<typeof RadioItem> &
101+
RadioItemVariants &
102+
CSSProps
170103

171104
export const ContextMenuRadioItem = forwardRef<
172-
ElementRef<typeof StyledContextMenuRadioItem>,
105+
ElementRef<typeof StyledRadioItem>,
173106
ContextMenuRadioItemProps
174107
>(({ children, ...props }, forwardedRef) => {
175108
return (
176-
<StyledContextMenuRadioItem {...props} ref={forwardedRef}>
109+
<StyledRadioItem {...props} ref={forwardedRef}>
177110
<StyledItemIndicator>
178-
<Check css={{ height: 16, width: 16 }} />
111+
<StyledCheckIndicator />
179112
</StyledItemIndicator>
180113
{children}
181-
</StyledContextMenuRadioItem>
114+
</StyledRadioItem>
182115
)
183116
})
184-
ContextMenuRadioItem.toString = () => `.${StyledContextMenuRadioItem.className}`
185-
186-
export const ContextMenuRadioGroup = RadioGroup
117+
ContextMenuRadioItem.toString = () => `.${StyledRadioItem.className}`

src/components/Menu/Menu.stories.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const Default: Story = (args) => (
4949
</Menu>
5050
)
5151

52-
export const WithDisabledItems: React.FC = () => (
52+
export const WithDisabledItems: Story = () => (
5353
<Menu>
5454
<MenuTrigger>
5555
<Button>Trigger</Button>
@@ -63,7 +63,7 @@ export const WithDisabledItems: React.FC = () => (
6363
)
6464

6565
/* Separators and Groups can be used to arrange items in vertical and horizontal sections */
66-
export const WithSeparators: React.FC = () => (
66+
export const WithSeparators: Story = () => (
6767
<Menu>
6868
<MenuTrigger>
6969
<Button>Trigger</Button>
@@ -88,7 +88,7 @@ export const WithSeparators: React.FC = () => (
8888
</Menu>
8989
)
9090

91-
export const WithLabel: React.FC = () => (
91+
export const WithLabel: Story = () => (
9292
<Menu>
9393
<MenuTrigger>
9494
<Button>Trigger</Button>
@@ -241,3 +241,40 @@ export const MultipleMenus: Story = () => {
241241
</>
242242
)
243243
}
244+
245+
/** use the `destructive` flag to show the item triggers a destructive action */
246+
export const Destructive: Story = () => {
247+
const [color, setColor] = React.useState('blue')
248+
const [checked, setChecked] = useState(true)
249+
return (
250+
<Menu>
251+
<MenuTrigger>
252+
<Button>Trigger</Button>
253+
</MenuTrigger>
254+
<MenuContent>
255+
<MenuItem>
256+
Open <MenuItemShortcut>⌘+O</MenuItemShortcut>
257+
</MenuItem>
258+
<MenuItem destructive>
259+
Delete <MenuItemShortcut>⌘+D</MenuItemShortcut>
260+
</MenuItem>
261+
<MenuSeparator />
262+
<MenuRadioGroup value={color} onValueChange={setColor}>
263+
<MenuRadioItem destructive value="red">
264+
Red
265+
</MenuRadioItem>
266+
<MenuRadioItem value="green">Green</MenuRadioItem>
267+
<MenuRadioItem value="blue">Blue</MenuRadioItem>
268+
</MenuRadioGroup>
269+
<MenuSeparator />
270+
<MenuCheckboxItem
271+
destructive
272+
checked={checked}
273+
onCheckedChange={setChecked}
274+
>
275+
Mark
276+
</MenuCheckboxItem>
277+
</MenuContent>
278+
</Menu>
279+
)
280+
}

0 commit comments

Comments
 (0)