Files
twenty/packages/twenty-front/src/modules/ui/feedback/snack-bar-manager/components/SnackBar.tsx
Raphaël Bosi 051f0fc83f Use data attributes for click outside instead of classNames (#12228)
We previously used classnames to exclude elements from the click outside
listener.

With this PR we can now use `data-click-outside-id` instead of
`classNames` to target the elements we want to exclude from the click
outside listener.

We can also add `data-globally-prevent-click-outside` to a component to
globally prevent triggering click outside listeners for other
components. This attribute is especially useful for confirmation modals
and snackbar items.

Fixes #11785:


https://github.com/user-attachments/assets/318baa7e-0f82-4e3a-a447-bf981328462d
2025-05-22 18:10:51 +02:00

230 lines
6.2 KiB
TypeScript

import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isUndefined } from '@sniptt/guards';
import { ComponentPropsWithoutRef, ReactNode, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import {
IconAlertTriangle,
IconInfoCircle,
IconSquareRoundedCheck,
IconX,
} from 'twenty-ui/display';
import { ProgressBar, useProgressAnimation } from 'twenty-ui/feedback';
import { LightButton, LightIconButton } from 'twenty-ui/input';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
export enum SnackBarVariant {
Default = 'default',
Error = 'error',
Success = 'success',
Info = 'info',
Warning = 'warning',
}
export type SnackBarProps = Pick<ComponentPropsWithoutRef<'div'>, 'id'> & {
className?: string;
progress?: number;
duration?: number;
icon?: ReactNode;
message: string;
link?: {
href: string;
text: string;
};
detailedMessage?: string;
onCancel?: () => void;
onClose?: () => void;
role?: 'alert' | 'status';
variant?: SnackBarVariant;
dedupeKey?: string;
};
const StyledContainer = styled.div`
backdrop-filter: ${({ theme }) => theme.blur.medium};
background-color: ${({ theme }) => theme.background.transparent.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
box-sizing: border-box;
cursor: pointer;
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
width: 296px;
margin-top: ${({ theme }) => theme.spacing(2)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
border-radius: 0;
width: 100%;
}
`;
const StyledProgressBar = styled(ProgressBar)`
bottom: 0;
height: auto;
left: 0;
position: absolute;
right: 0;
top: 0;
pointer-events: none;
`;
const StyledHeader = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledMessage = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledIcon = styled.div`
align-items: center;
display: flex;
`;
const StyledActions = styled.div`
align-items: center;
display: flex;
margin-left: auto;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
padding-left: ${({ theme }) => theme.spacing(6)};
overflow: hidden;
text-overflow: ellipsis;
width: 200px;
`;
const StyledLink = styled(Link)`
display: block;
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
padding-left: ${({ theme }) => theme.spacing(6)};
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 200px;
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
const defaultAriaLabelByVariant: Record<SnackBarVariant, string> = {
[SnackBarVariant.Default]: 'Alert',
[SnackBarVariant.Error]: 'Error',
[SnackBarVariant.Info]: 'Info',
[SnackBarVariant.Success]: 'Success',
[SnackBarVariant.Warning]: 'Warning',
};
export const SnackBar = ({
className,
progress: overrideProgressValue,
duration = 6000,
icon: iconComponent,
id,
message,
detailedMessage,
link,
onCancel,
onClose,
role = 'status',
variant = SnackBarVariant.Default,
}: SnackBarProps) => {
const theme = useTheme();
const { t } = useLingui();
const { animation: progressAnimation, value: progressValue } =
useProgressAnimation({
autoPlay: isUndefined(overrideProgressValue),
initialValue: isDefined(overrideProgressValue)
? overrideProgressValue
: 100,
finalValue: 0,
options: { duration, onComplete: onClose },
});
const icon = useMemo(() => {
if (isDefined(iconComponent)) {
return iconComponent;
}
const ariaLabel = defaultAriaLabelByVariant[variant];
const color = theme.snackBar[variant].color;
const size = theme.icon.size.md;
switch (variant) {
case SnackBarVariant.Error:
return (
<IconAlertTriangle {...{ 'aria-label': ariaLabel, color, size }} />
);
case SnackBarVariant.Info:
return <IconInfoCircle {...{ 'aria-label': ariaLabel, color, size }} />;
case SnackBarVariant.Success:
return (
<IconSquareRoundedCheck
{...{ 'aria-label': ariaLabel, color, size }}
/>
);
case SnackBarVariant.Warning:
return (
<IconAlertTriangle {...{ 'aria-label': ariaLabel, color, size }} />
);
default:
return (
<IconAlertTriangle {...{ 'aria-label': ariaLabel, color, size }} />
);
}
}, [iconComponent, theme.icon.size.md, theme.snackBar, variant]);
const handleMouseEnter = () => {
if (progressAnimation?.state === 'running') {
progressAnimation.pause();
}
};
const handleMouseLeave = () => {
if (progressAnimation?.state === 'paused') {
progressAnimation.play();
}
};
return (
<StyledContainer
aria-live={role === 'alert' ? 'assertive' : 'polite'}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={message || defaultAriaLabelByVariant[variant]}
{...{ className, id, role, variant }}
data-globally-prevent-click-outside
>
<StyledProgressBar
barColor={theme.snackBar[variant].backgroundColor}
value={progressValue}
/>
<StyledHeader>
<StyledIcon>{icon}</StyledIcon>
<StyledMessage>{message}</StyledMessage>
<StyledActions>
{!!onCancel && <LightButton title={t`Cancel`} onClick={onCancel} />}
{!!onClose && (
<LightIconButton title={t`Close`} Icon={IconX} onClick={onClose} />
)}
</StyledActions>
</StyledHeader>
{detailedMessage && (
<StyledDescription>{detailedMessage}</StyledDescription>
)}
{link && <StyledLink to={link.href}>{link.text}</StyledLink>}
</StyledContainer>
);
};