Skip to content

Commit 913f14e

Browse files
pt-tslpedrotpo
andauthored
BA-1831: Action overlay (#141)
* feat: add overlay actions wrapper component * feat: add overlay actions to ChatRoomCard * feat: add overlay actions to CommentItem * chore: versioning --------- Co-authored-by: Pedro Tibúrcio <pedrotpoliveira@gmail.com>
1 parent ef0aef0 commit 913f14e

File tree

24 files changed

+656
-351
lines changed

24 files changed

+656
-351
lines changed

packages/components/CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @baseapp-frontend/components
22

3+
## 0.0.21
4+
5+
### Patch Changes
6+
7+
- Removed CommentOptions from CommentItem component and refactored into ActionsOverlay. Applied ActionsOverlay to CommentItem and ChatRoomItem components.
8+
- Updated dependencies
9+
- @baseapp-frontend/design-system@0.0.22
10+
311
## 0.0.20
412

513
### Patch Changes
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Meta } from '@storybook/addon-docs'
2+
3+
<Meta title="@baseapp-frontend | components/Shared/ActionsOverlay" />
4+
5+
# Component Documentation
6+
7+
## ActionsOverlay
8+
9+
- **Purpose**: The `ActionsOverlay` component wraps around any other component and provides it with a tooltip/swippable drawer that receives a list of custom actions the user wished to assign to the wrapped component.
10+
- **Expected Behavior**: In mobile view, when long press on the child component, a swippable drawer will appear, containing all the actions (in the form of icon/label pair) the user configured. In web view, the actions appear on hover of the child element, and are displayed in a tooltip containing only the icons of the configured actions.
11+
12+
## Use Cases
13+
14+
- **Current Usage**: This component is currently used within `CommentItem` and `ChatRoomItem`.
15+
- **Potential Usage**: Can de used for any other component that requires additional actions other than the base action provided by that component, such as posts, list items, etc.
16+
17+
## Props
18+
19+
- **actions** (OverlayAction[]): The list of actions desired for the child component. Note that to implement a delete action, the component provides a enabler, loading and click handler props specifically for that action (see props below).
20+
- **title** (string): Title for the child component (currently only used on the delete dialog).
21+
- **children** (ReactNode): The child component to be wrapped.
22+
- **enableDelete** (boolean): Enables the delete action inside the tooltip/swippable drawer.
23+
- **isDeletingItem** (boolean): Mutation loading state for the delete action.
24+
- **handleDeleteItem** (function): Callback function to handle deletion.
25+
- **offsetTop** (number): Number to offset the top positioning of the default tooltip position (only affects tooltip).
26+
- **offsetRight** (number): Number to offset the right positioning of the default tooltip position (only affects tooltip).
27+
- **ContainerProps** (BoxProps): Props for the parent `Box` component that wraps the child component.
28+
- **SwipeableDrawer** (`FC<SwipeableDrawerProps>`): `SwipeableDrawer` component. Defaults to current MUI component.
29+
- **SwipeableDrawerProps** (`Partial<SwipeableDrawerProps>`): Props extension for the parent `Box` that wraps the child component.
30+
31+
## Example Usage
32+
33+
```javascript
34+
import React, { RefAttributes } from 'react'
35+
36+
import { Button } from '@mui/material'
37+
38+
import ActionsOverlay from '../'
39+
import { ActionOverlayProps } from '../types'
40+
41+
export const DefaultActionsOverlay = (
42+
props: Omit<ActionOverlayProps, 'ref'> & RefAttributes<HTMLDivElement>,
43+
) => {
44+
const pageRef = React.useRef<HTMLDivElement>(null)
45+
return (
46+
<ActionsOverlay
47+
title='Button',
48+
enableDelete={true}
49+
handleDeleteItem={() => {}}
50+
actions={[
51+
{
52+
label: 'Archive',
53+
icon: <ArchiveIcon />,
54+
onClick: () => {},
55+
hasPermission: true,
56+
closeOnClick: true,
57+
},
58+
]},
59+
ref={pageRef}
60+
>
61+
<Button>Button with overlay</Button>
62+
</ActionsOverlay>
63+
)
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React, { RefAttributes } from 'react'
2+
3+
import { Button } from '@mui/material'
4+
5+
import ActionsOverlay from '../..'
6+
import { ActionOverlayProps } from '../../types'
7+
8+
const ActionsOverlayOnButton = (
9+
props: Omit<ActionOverlayProps, 'ref'> & RefAttributes<HTMLDivElement>,
10+
) => {
11+
const pageRef = React.useRef<HTMLDivElement>(null)
12+
return (
13+
<ActionsOverlay {...props} ref={pageRef}>
14+
<Button sx={{ width: 300, height: 150 }}>Button with overlay</Button>
15+
</ActionsOverlay>
16+
)
17+
}
18+
19+
export default ActionsOverlayOnButton
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { ArchiveIcon } from '@baseapp-frontend/design-system'
2+
3+
import type { Meta, StoryObj } from '@storybook/react'
4+
5+
import ActionsOverlay from '..'
6+
import ActionsOverlayOnButton from './ActionsOverlayOnButton'
7+
8+
const meta: Meta<typeof ActionsOverlay> = {
9+
title: '@baseapp-frontend | components/Shared/ActionsOverlay',
10+
component: ActionsOverlayOnButton,
11+
}
12+
13+
export default meta
14+
15+
type Story = StoryObj<typeof ActionsOverlay>
16+
17+
export const DefaultActionsOverlay: Story = {
18+
name: 'ActionsOverlay',
19+
args: {
20+
title: 'Button',
21+
enableDelete: true,
22+
handleDeleteItem: () => {},
23+
offsetRight: 0,
24+
offsetTop: 0,
25+
ContainerProps: {
26+
sx: { width: '100px' },
27+
},
28+
actions: [
29+
{
30+
label: 'Archive',
31+
icon: <ArchiveIcon />,
32+
onClick: () => {},
33+
hasPermission: true,
34+
closeOnClick: true,
35+
},
36+
],
37+
},
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { forwardRef, useState } from 'react'
2+
3+
import {
4+
ConfirmDialog,
5+
SwipeableDrawer as DefaultSwipeableDrawer,
6+
IconButton,
7+
TrashCanIcon,
8+
} from '@baseapp-frontend/design-system'
9+
10+
import { LoadingButton } from '@mui/lab'
11+
import { Box, Divider, Typography } from '@mui/material'
12+
import { LongPressCallbackReason, useLongPress } from 'use-long-press'
13+
14+
import { ActionOverlayContainer, IconButtonContentContainer } from './styled'
15+
import { ActionOverlayProps, LongPressHandler } from './types'
16+
17+
const ActionsOverlay = forwardRef<HTMLDivElement, ActionOverlayProps>(
18+
(
19+
{
20+
actions = [],
21+
children,
22+
title = 'Item',
23+
enableDelete = false,
24+
isDeletingItem = false,
25+
handleDeleteItem = () => {},
26+
offsetTop = 0,
27+
offsetRight = 0,
28+
ContainerProps = {},
29+
SwipeableDrawerProps = {},
30+
SwipeableDrawer = DefaultSwipeableDrawer,
31+
},
32+
ref,
33+
) => {
34+
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
35+
const [isHoveringItem, setIsHoveringItem] = useState(false)
36+
const [longPressHandler, setLongPressHandler] = useState<LongPressHandler>({
37+
isLongPressingItem: false,
38+
shouldOpenItemOptions: false,
39+
})
40+
41+
const longPressHandlers = useLongPress<HTMLDivElement>(
42+
(e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
43+
e.stopPropagation()
44+
setLongPressHandler({ isLongPressingItem: true, shouldOpenItemOptions: true })
45+
},
46+
{
47+
onCancel: (e, { reason }) => {
48+
// This is a workaround to prevent the comment options's drawer from closing when the user clicks on the drawer's content.
49+
// Ideally, we would call setLongPressHandler({ isLongPressingComment: false }) on `onFinished` instead of `onCancel`.
50+
// But, on mobile google chrome devices, the long press click is being wrongly propagated to the backdrop and closing the comment options's drawer right after it opens.
51+
const className = (e?.target as HTMLElement)?.className || ''
52+
const classNameString = typeof className === 'string' ? className : ''
53+
const isClickOnBackdrop = classNameString.includes('MuiBackdrop')
54+
if (reason === LongPressCallbackReason.CancelledByRelease && isClickOnBackdrop) {
55+
setLongPressHandler((prevState) => ({ ...prevState, isLongPressingItem: false }))
56+
}
57+
},
58+
cancelOutsideElement: true,
59+
threshold: 400,
60+
},
61+
)
62+
const handleLongPressItemOptionsClose = () => {
63+
setLongPressHandler({ isLongPressingItem: false, shouldOpenItemOptions: false })
64+
}
65+
66+
const handleDeleteDialogOpen = () => {
67+
setIsDeleteDialogOpen(true)
68+
}
69+
70+
const deviceHasHover =
71+
typeof window !== 'undefined' && window.matchMedia('(hover: hover)').matches
72+
73+
const onDeleteItemClick = () => {
74+
setIsDeleteDialogOpen(false)
75+
handleDeleteItem?.()
76+
handleLongPressItemOptionsClose()
77+
}
78+
79+
const renderDeleteDialog = () => (
80+
<ConfirmDialog
81+
title={`Delete ${title}?`}
82+
content="Are you sure you want to delete? This action cannot be undone."
83+
action={
84+
<LoadingButton
85+
color="error"
86+
onClick={onDeleteItemClick}
87+
disabled={isDeletingItem}
88+
loading={isDeletingItem}
89+
>
90+
Delete
91+
</LoadingButton>
92+
}
93+
onClose={() => setIsDeleteDialogOpen(false)}
94+
open={isDeleteDialogOpen}
95+
/>
96+
)
97+
98+
const renderActionsOverlay = () => {
99+
if (!deviceHasHover) {
100+
const handleDrawerClose = () => {
101+
if (!longPressHandler.isLongPressingItem) {
102+
handleLongPressItemOptionsClose()
103+
}
104+
}
105+
106+
return (
107+
<SwipeableDrawer
108+
open={longPressHandler.shouldOpenItemOptions && longPressHandler.isLongPressingItem}
109+
onClose={handleDrawerClose}
110+
aria-label="actions overlay"
111+
{...SwipeableDrawerProps}
112+
>
113+
<Box display="grid" gridTemplateColumns="1fr" justifySelf="start" gap={1}>
114+
{actions?.map(({ label, icon, onClick, disabled, hasPermission, closeOnClick }) => {
115+
if (!hasPermission) return null
116+
117+
const handleClick = () => {
118+
onClick?.()
119+
if (closeOnClick) {
120+
handleLongPressItemOptionsClose()
121+
}
122+
}
123+
124+
return (
125+
<IconButton
126+
key={label}
127+
onClick={handleClick}
128+
disabled={disabled}
129+
sx={{ width: 'fit-content' }}
130+
aria-label={label}
131+
>
132+
<IconButtonContentContainer>
133+
<Box display="grid" justifySelf="center" height="min-content">
134+
{icon}
135+
</Box>
136+
<Typography variant="body2">{label}</Typography>
137+
</IconButtonContentContainer>
138+
</IconButton>
139+
)
140+
})}
141+
{enableDelete && (
142+
<>
143+
<Divider />
144+
<IconButton
145+
onClick={handleDeleteDialogOpen}
146+
disabled={isDeletingItem}
147+
sx={{ width: 'fit-content' }}
148+
aria-label="delete item"
149+
>
150+
<IconButtonContentContainer>
151+
<Box display="grid" justifySelf="center" height="min-content">
152+
<TrashCanIcon />
153+
</Box>
154+
<Typography variant="body2" color="error.main">
155+
{`Delete ${title}`}
156+
</Typography>
157+
</IconButtonContentContainer>
158+
</IconButton>
159+
</>
160+
)}
161+
</Box>
162+
</SwipeableDrawer>
163+
)
164+
}
165+
166+
if (deviceHasHover && isHoveringItem) {
167+
return (
168+
<ActionOverlayContainer
169+
offsetRight={offsetRight}
170+
offsetTop={offsetTop}
171+
aria-label="actions overlay"
172+
>
173+
{enableDelete && (
174+
<IconButton
175+
onClick={handleDeleteDialogOpen}
176+
disabled={isDeletingItem}
177+
aria-label="delete item"
178+
>
179+
<TrashCanIcon />
180+
</IconButton>
181+
)}
182+
{actions?.map(({ label, icon, onClick, disabled, hasPermission, closeOnClick }) => {
183+
if (!hasPermission) return null
184+
185+
const handleClick = () => {
186+
onClick?.()
187+
if (closeOnClick) {
188+
handleLongPressItemOptionsClose()
189+
}
190+
}
191+
192+
return (
193+
<IconButton
194+
key={label}
195+
onClick={handleClick}
196+
disabled={disabled}
197+
aria-label={label}
198+
>
199+
{icon}
200+
</IconButton>
201+
)
202+
})}
203+
</ActionOverlayContainer>
204+
)
205+
}
206+
return <div />
207+
}
208+
209+
return (
210+
<Box
211+
ref={ref}
212+
onMouseEnter={() => setIsHoveringItem(true)}
213+
onMouseLeave={() => setIsHoveringItem(false)}
214+
{...longPressHandlers()}
215+
{...ContainerProps}
216+
sx={{ position: 'relative', maxWidth: 'max-content' }}
217+
>
218+
{renderDeleteDialog()}
219+
{renderActionsOverlay()}
220+
{children}
221+
</Box>
222+
)
223+
},
224+
)
225+
226+
export default ActionsOverlay
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Box } from '@mui/material'
2+
import { styled } from '@mui/material/styles'
3+
4+
import { ActionOverlayContainerProps } from './types'
5+
6+
export const ActionOverlayContainer = styled(Box)<ActionOverlayContainerProps>(
7+
({ theme, offsetTop = 0, offsetRight = 0 }) => ({
8+
backgroundColor: theme.palette.background.default,
9+
border: `1px solid ${theme.palette.divider}`,
10+
borderRadius: theme.spacing(1),
11+
display: 'flex',
12+
gap: theme.spacing(1),
13+
padding: theme.spacing(0.75, 1),
14+
position: 'absolute',
15+
role: 'menu',
16+
'aria-label': 'Action options',
17+
right: 12 - offsetRight,
18+
top: -12 - offsetTop,
19+
zIndex: theme.zIndex.tooltip,
20+
transition: theme.transitions.create(['opacity', 'visibility'], {
21+
duration: theme.transitions.duration.shorter,
22+
easing: theme.transitions.easing.easeInOut,
23+
}),
24+
}),
25+
)
26+
27+
export const IconButtonContentContainer = styled(Box)(({ theme }) => ({
28+
alignItems: 'center',
29+
display: 'grid',
30+
gridTemplateColumns: 'minmax(max-content, 24px) 1fr',
31+
gap: theme.spacing(1),
32+
alignSelf: 'center',
33+
}))

0 commit comments

Comments
 (0)