Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @baseapp-frontend/components

## 0.0.21

### Patch Changes

- Removed CommentOptions from CommentItem component and refactored into ActionsOverlay. Applied ActionsOverlay to CommentItem and ChatRoomItem components.
- Updated dependencies
- @baseapp-frontend/design-system@0.0.22

## 0.0.20

### Patch Changes
Expand Down
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'

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,
},
],
},
}
Comment on lines +17 to +38
Copy link

Choose a reason for hiding this comment

The 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:

  • Different screen sizes
  • Various overlay positions
  • Mobile behavior

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:

  • Should appear within 1 second of hover
  • Should disappear immediately when mouse leaves

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();
  },
};

226 changes: 226 additions & 0 deletions packages/components/modules/__shared__/ActionsOverlay/index.tsx
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',
}))
Loading
Loading