-
Notifications
You must be signed in to change notification settings - Fork 2
BA-1831: Action overlay #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { Meta } from '@storybook/addon-docs' | ||
|
|
||
| <Meta title="@baseapp-frontend | components/Shared/ActionsOverlay" /> | ||
|
|
||
| # Component Documentation | ||
|
|
||
| ## ActionsOverlay | ||
|
|
||
| - **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. | ||
| - **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. | ||
|
|
||
| ## Use Cases | ||
|
|
||
| - **Current Usage**: This component is currently used within `CommentItem` and `ChatRoomItem`. | ||
| - **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. | ||
|
|
||
| ## Props | ||
|
|
||
| - **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). | ||
| - **title** (string): Title for the child component (currently only used on the delete dialog). | ||
| - **children** (ReactNode): The child component to be wrapped. | ||
| - **enableDelete** (boolean): Enables the delete action inside the tooltip/swippable drawer. | ||
| - **isDeletingItem** (boolean): Mutation loading state for the delete action. | ||
| - **handleDeleteItem** (function): Callback function to handle deletion. | ||
| - **offsetTop** (number): Number to offset the top positioning of the default tooltip position (only affects tooltip). | ||
| - **offsetRight** (number): Number to offset the right positioning of the default tooltip position (only affects tooltip). | ||
| - **ContainerProps** (BoxProps): Props for the parent `Box` component that wraps the child component. | ||
| - **SwipeableDrawer** (`FC<SwipeableDrawerProps>`): `SwipeableDrawer` component. Defaults to current MUI component. | ||
| - **SwipeableDrawerProps** (`Partial<SwipeableDrawerProps>`): Props extension for the parent `Box` that wraps the child component. | ||
|
|
||
| ## Example Usage | ||
|
|
||
| ```javascript | ||
| import React, { RefAttributes } from 'react' | ||
|
|
||
| import { Button } from '@mui/material' | ||
|
|
||
| import ActionsOverlay from '../' | ||
| import { ActionOverlayProps } from '../types' | ||
|
|
||
| export const DefaultActionsOverlay = ( | ||
| props: Omit<ActionOverlayProps, 'ref'> & RefAttributes<HTMLDivElement>, | ||
| ) => { | ||
| const pageRef = React.useRef<HTMLDivElement>(null) | ||
| return ( | ||
| <ActionsOverlay | ||
| title='Button', | ||
| enableDelete={true} | ||
| handleDeleteItem={() => {}} | ||
| actions={[ | ||
| { | ||
| label: 'Archive', | ||
| icon: <ArchiveIcon />, | ||
| onClick: () => {}, | ||
| hasPermission: true, | ||
| closeOnClick: true, | ||
| }, | ||
| ]}, | ||
| ref={pageRef} | ||
| > | ||
| <Button>Button with overlay</Button> | ||
| </ActionsOverlay> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React, { RefAttributes } from 'react' | ||
|
|
||
| import { Button } from '@mui/material' | ||
|
|
||
| import ActionsOverlay from '../..' | ||
| import { ActionOverlayProps } from '../../types' | ||
|
|
||
| const ActionsOverlayOnButton = ( | ||
| props: Omit<ActionOverlayProps, 'ref'> & RefAttributes<HTMLDivElement>, | ||
| ) => { | ||
| const pageRef = React.useRef<HTMLDivElement>(null) | ||
| return ( | ||
| <ActionsOverlay {...props} ref={pageRef}> | ||
| <Button sx={{ width: 300, height: 150 }}>Button with overlay</Button> | ||
| </ActionsOverlay> | ||
| ) | ||
| } | ||
|
|
||
| export default ActionsOverlayOnButton | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { ArchiveIcon } from '@baseapp-frontend/design-system' | ||
|
|
||
| import type { Meta, StoryObj } from '@storybook/react' | ||
|
|
||
| import ActionsOverlay from '..' | ||
| import ActionsOverlayOnButton from './ActionsOverlayOnButton' | ||
|
|
||
pt-tsl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const meta: Meta<typeof ActionsOverlay> = { | ||
| title: '@baseapp-frontend | components/Shared/ActionsOverlay', | ||
| component: ActionsOverlayOnButton, | ||
| } | ||
|
|
||
| export default meta | ||
|
|
||
| type Story = StoryObj<typeof ActionsOverlay> | ||
|
|
||
| export const DefaultActionsOverlay: Story = { | ||
| name: 'ActionsOverlay', | ||
| args: { | ||
| title: 'Button', | ||
| enableDelete: true, | ||
| handleDeleteItem: () => {}, | ||
| offsetRight: 0, | ||
| offsetTop: 0, | ||
| ContainerProps: { | ||
| sx: { width: '100px' }, | ||
| }, | ||
| actions: [ | ||
| { | ||
| label: 'Archive', | ||
| icon: <ArchiveIcon />, | ||
| onClick: () => {}, | ||
| hasPermission: true, | ||
| closeOnClick: true, | ||
| }, | ||
| ], | ||
pt-tsl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| } | ||
|
Comment on lines
+17
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add stories demonstrating responsive behavior The PR objectives specify that the overlay must behave consistently across all devices and screen sizes. Consider adding additional stories that demonstrate:
Add stories like: export const MobileOverlay: Story = {
name: 'Mobile View',
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
args: {
// ... mobile specific args
},
}
export const TabletOverlay: Story = {
name: 'Tablet View',
parameters: {
viewport: { defaultViewport: 'tablet' },
},
args: {
// ... tablet specific args
},
}🛠️ Refactor suggestion Add interaction tests for hover behavior The PR objectives specify specific timing requirements for the overlay:
Consider adding interaction tests to verify this behavior. Add an interaction test: export const HoverBehavior: Story = {
name: 'Hover Behavior',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// Test hover appearance
await userEvent.hover(button);
// Verify overlay appears within 1 second
await waitFor(() => {
expect(canvas.getByRole('menu')).toBeVisible();
}, { timeout: 1000 });
// Test hover disappearance
await userEvent.unhover(button);
// Verify immediate disappearance
expect(canvas.queryByRole('menu')).not.toBeInTheDocument();
},
}; |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,226 @@ | ||
| import { forwardRef, useState } from 'react' | ||
|
|
||
| import { | ||
| ConfirmDialog, | ||
| SwipeableDrawer as DefaultSwipeableDrawer, | ||
| IconButton, | ||
| TrashCanIcon, | ||
| } from '@baseapp-frontend/design-system' | ||
|
|
||
| import { LoadingButton } from '@mui/lab' | ||
| import { Box, Divider, Typography } from '@mui/material' | ||
| import { LongPressCallbackReason, useLongPress } from 'use-long-press' | ||
|
|
||
| import { ActionOverlayContainer, IconButtonContentContainer } from './styled' | ||
| import { ActionOverlayProps, LongPressHandler } from './types' | ||
|
|
||
| const ActionsOverlay = forwardRef<HTMLDivElement, ActionOverlayProps>( | ||
| ( | ||
| { | ||
| actions = [], | ||
| children, | ||
| title = 'Item', | ||
| enableDelete = false, | ||
| isDeletingItem = false, | ||
| handleDeleteItem = () => {}, | ||
| offsetTop = 0, | ||
| offsetRight = 0, | ||
| ContainerProps = {}, | ||
| SwipeableDrawerProps = {}, | ||
| SwipeableDrawer = DefaultSwipeableDrawer, | ||
| }, | ||
| ref, | ||
| ) => { | ||
| const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) | ||
| const [isHoveringItem, setIsHoveringItem] = useState(false) | ||
| const [longPressHandler, setLongPressHandler] = useState<LongPressHandler>({ | ||
| isLongPressingItem: false, | ||
| shouldOpenItemOptions: false, | ||
| }) | ||
|
|
||
| const longPressHandlers = useLongPress<HTMLDivElement>( | ||
| (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => { | ||
| e.stopPropagation() | ||
| setLongPressHandler({ isLongPressingItem: true, shouldOpenItemOptions: true }) | ||
| }, | ||
| { | ||
| onCancel: (e, { reason }) => { | ||
| // This is a workaround to prevent the comment options's drawer from closing when the user clicks on the drawer's content. | ||
| // Ideally, we would call setLongPressHandler({ isLongPressingComment: false }) on `onFinished` instead of `onCancel`. | ||
| // 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. | ||
| const className = (e?.target as HTMLElement)?.className || '' | ||
| const classNameString = typeof className === 'string' ? className : '' | ||
| const isClickOnBackdrop = classNameString.includes('MuiBackdrop') | ||
| if (reason === LongPressCallbackReason.CancelledByRelease && isClickOnBackdrop) { | ||
| setLongPressHandler((prevState) => ({ ...prevState, isLongPressingItem: false })) | ||
| } | ||
| }, | ||
| cancelOutsideElement: true, | ||
| threshold: 400, | ||
| }, | ||
| ) | ||
| const handleLongPressItemOptionsClose = () => { | ||
| setLongPressHandler({ isLongPressingItem: false, shouldOpenItemOptions: false }) | ||
| } | ||
|
|
||
| const handleDeleteDialogOpen = () => { | ||
| setIsDeleteDialogOpen(true) | ||
| } | ||
|
|
||
| const deviceHasHover = | ||
| typeof window !== 'undefined' && window.matchMedia('(hover: hover)').matches | ||
|
|
||
| const onDeleteItemClick = () => { | ||
| setIsDeleteDialogOpen(false) | ||
| handleDeleteItem?.() | ||
| handleLongPressItemOptionsClose() | ||
| } | ||
|
|
||
| const renderDeleteDialog = () => ( | ||
| <ConfirmDialog | ||
| title={`Delete ${title}?`} | ||
| content="Are you sure you want to delete? This action cannot be undone." | ||
| action={ | ||
| <LoadingButton | ||
| color="error" | ||
| onClick={onDeleteItemClick} | ||
| disabled={isDeletingItem} | ||
| loading={isDeletingItem} | ||
| > | ||
| Delete | ||
| </LoadingButton> | ||
| } | ||
| onClose={() => setIsDeleteDialogOpen(false)} | ||
| open={isDeleteDialogOpen} | ||
| /> | ||
| ) | ||
|
|
||
| const renderActionsOverlay = () => { | ||
| if (!deviceHasHover) { | ||
| const handleDrawerClose = () => { | ||
| if (!longPressHandler.isLongPressingItem) { | ||
| handleLongPressItemOptionsClose() | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <SwipeableDrawer | ||
| open={longPressHandler.shouldOpenItemOptions && longPressHandler.isLongPressingItem} | ||
| onClose={handleDrawerClose} | ||
| aria-label="actions overlay" | ||
| {...SwipeableDrawerProps} | ||
| > | ||
| <Box display="grid" gridTemplateColumns="1fr" justifySelf="start" gap={1}> | ||
| {actions?.map(({ label, icon, onClick, disabled, hasPermission, closeOnClick }) => { | ||
| if (!hasPermission) return null | ||
|
|
||
| const handleClick = () => { | ||
| onClick?.() | ||
| if (closeOnClick) { | ||
| handleLongPressItemOptionsClose() | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <IconButton | ||
| key={label} | ||
| onClick={handleClick} | ||
| disabled={disabled} | ||
| sx={{ width: 'fit-content' }} | ||
| aria-label={label} | ||
| > | ||
| <IconButtonContentContainer> | ||
| <Box display="grid" justifySelf="center" height="min-content"> | ||
| {icon} | ||
| </Box> | ||
| <Typography variant="body2">{label}</Typography> | ||
| </IconButtonContentContainer> | ||
| </IconButton> | ||
| ) | ||
| })} | ||
| {enableDelete && ( | ||
| <> | ||
| <Divider /> | ||
| <IconButton | ||
| onClick={handleDeleteDialogOpen} | ||
| disabled={isDeletingItem} | ||
| sx={{ width: 'fit-content' }} | ||
| aria-label="delete item" | ||
| > | ||
| <IconButtonContentContainer> | ||
| <Box display="grid" justifySelf="center" height="min-content"> | ||
| <TrashCanIcon /> | ||
| </Box> | ||
| <Typography variant="body2" color="error.main"> | ||
| {`Delete ${title}`} | ||
| </Typography> | ||
| </IconButtonContentContainer> | ||
| </IconButton> | ||
| </> | ||
| )} | ||
| </Box> | ||
| </SwipeableDrawer> | ||
| ) | ||
| } | ||
|
|
||
| if (deviceHasHover && isHoveringItem) { | ||
| return ( | ||
| <ActionOverlayContainer | ||
| offsetRight={offsetRight} | ||
| offsetTop={offsetTop} | ||
| aria-label="actions overlay" | ||
| > | ||
| {enableDelete && ( | ||
| <IconButton | ||
| onClick={handleDeleteDialogOpen} | ||
| disabled={isDeletingItem} | ||
| aria-label="delete item" | ||
| > | ||
| <TrashCanIcon /> | ||
| </IconButton> | ||
| )} | ||
| {actions?.map(({ label, icon, onClick, disabled, hasPermission, closeOnClick }) => { | ||
| if (!hasPermission) return null | ||
|
|
||
| const handleClick = () => { | ||
| onClick?.() | ||
| if (closeOnClick) { | ||
| handleLongPressItemOptionsClose() | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <IconButton | ||
| key={label} | ||
| onClick={handleClick} | ||
| disabled={disabled} | ||
| aria-label={label} | ||
| > | ||
| {icon} | ||
| </IconButton> | ||
| ) | ||
| })} | ||
| </ActionOverlayContainer> | ||
| ) | ||
| } | ||
| return <div /> | ||
| } | ||
|
|
||
| return ( | ||
| <Box | ||
| ref={ref} | ||
| onMouseEnter={() => setIsHoveringItem(true)} | ||
| onMouseLeave={() => setIsHoveringItem(false)} | ||
| {...longPressHandlers()} | ||
| {...ContainerProps} | ||
| sx={{ position: 'relative', maxWidth: 'max-content' }} | ||
| > | ||
| {renderDeleteDialog()} | ||
| {renderActionsOverlay()} | ||
| {children} | ||
| </Box> | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| export default ActionsOverlay |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { Box } from '@mui/material' | ||
| import { styled } from '@mui/material/styles' | ||
|
|
||
| import { ActionOverlayContainerProps } from './types' | ||
|
|
||
| export const ActionOverlayContainer = styled(Box)<ActionOverlayContainerProps>( | ||
| ({ theme, offsetTop = 0, offsetRight = 0 }) => ({ | ||
| backgroundColor: theme.palette.background.default, | ||
| border: `1px solid ${theme.palette.divider}`, | ||
| borderRadius: theme.spacing(1), | ||
| display: 'flex', | ||
| gap: theme.spacing(1), | ||
| padding: theme.spacing(0.75, 1), | ||
| position: 'absolute', | ||
| role: 'menu', | ||
| 'aria-label': 'Action options', | ||
| right: 12 - offsetRight, | ||
| top: -12 - offsetTop, | ||
| zIndex: theme.zIndex.tooltip, | ||
| transition: theme.transitions.create(['opacity', 'visibility'], { | ||
| duration: theme.transitions.duration.shorter, | ||
| easing: theme.transitions.easing.easeInOut, | ||
| }), | ||
| }), | ||
| ) | ||
|
|
||
| export const IconButtonContentContainer = styled(Box)(({ theme }) => ({ | ||
| alignItems: 'center', | ||
| display: 'grid', | ||
| gridTemplateColumns: 'minmax(max-content, 24px) 1fr', | ||
| gap: theme.spacing(1), | ||
| alignSelf: 'center', | ||
| })) |
Uh oh!
There was an error while loading. Please reload this page.