Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
182
front/src/modules/ui/snack-bar/components/SnackBar.tsx
Normal file
182
front/src/modules/ui/snack-bar/components/SnackBar.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconAlertTriangle, IconX } from '@/ui/icon';
|
||||
import {
|
||||
ProgressBar,
|
||||
ProgressBarControls,
|
||||
} from '@/ui/progress-bar/components/ProgressBar';
|
||||
import { rgba } from '@/ui/themes/colors';
|
||||
|
||||
import { usePausableTimeout } from '../hooks/usePausableTimeout';
|
||||
|
||||
const StyledMotionContainer = styled.div<Pick<SnackbarProps, 'variant'>>`
|
||||
align-items: center;
|
||||
background-color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.snackBar.error.background;
|
||||
case 'success':
|
||||
return theme.snackBar.success.background;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.color.gray80;
|
||||
}
|
||||
}};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.snackBar.error.color;
|
||||
case 'success':
|
||||
return theme.snackBar.success.color;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.color.gray0;
|
||||
}
|
||||
}};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
display: flex;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const ProgressBarContainer = styled.div`
|
||||
height: 5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button<Pick<SnackbarProps, 'variant'>>`
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: ${({ theme, variant }) => {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return theme.color.red20;
|
||||
case 'success':
|
||||
return theme.color.turquoise20;
|
||||
case 'info':
|
||||
default:
|
||||
return theme.color.gray0;
|
||||
}
|
||||
}};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
margin-left: ${({ theme }) => theme.spacing(6)};
|
||||
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => rgba(theme.color.gray0, 0.1)};
|
||||
}
|
||||
`;
|
||||
|
||||
export type SnackbarVariant = 'info' | 'error' | 'success';
|
||||
|
||||
export interface SnackbarProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
role?: 'alert' | 'status';
|
||||
icon?: React.ReactNode;
|
||||
message?: string;
|
||||
allowDismiss?: boolean;
|
||||
duration?: number;
|
||||
variant?: SnackbarVariant;
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function SnackBar({
|
||||
role = 'status',
|
||||
icon: iconComponent,
|
||||
message,
|
||||
allowDismiss = true,
|
||||
duration = 6000,
|
||||
variant = 'info',
|
||||
children,
|
||||
onClose,
|
||||
...rootProps
|
||||
}: SnackbarProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const progressBarRef = useRef<ProgressBarControls | null>(null);
|
||||
|
||||
const closeSnackbar = useCallback(() => {
|
||||
onClose && onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const { pauseTimeout, resumeTimeout } = usePausableTimeout(
|
||||
closeSnackbar,
|
||||
duration,
|
||||
);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
if (iconComponent) {
|
||||
return iconComponent;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return (
|
||||
<IconAlertTriangle aria-label="Error" size={theme.icon.size.md} />
|
||||
);
|
||||
case 'success':
|
||||
case 'info':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [iconComponent, theme.icon.size.md, variant]);
|
||||
|
||||
const onMouseEnter = () => {
|
||||
progressBarRef.current?.pause();
|
||||
pauseTimeout();
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
progressBarRef.current?.start();
|
||||
resumeTimeout();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledMotionContainer
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
role={role}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
variant={variant}
|
||||
{...rootProps}
|
||||
>
|
||||
<ProgressBarContainer>
|
||||
<ProgressBar
|
||||
ref={progressBarRef}
|
||||
barHeight={5}
|
||||
barColor={rgba(theme.color.gray0, 0.3)}
|
||||
duration={duration}
|
||||
/>
|
||||
</ProgressBarContainer>
|
||||
{icon && <StyledIconContainer>{icon}</StyledIconContainer>}
|
||||
{children ? children : message}
|
||||
{allowDismiss && (
|
||||
<CloseButton variant={variant} onClick={closeSnackbar}>
|
||||
<IconX aria-label="Close" size={theme.icon.size.md} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</StyledMotionContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { snackBarInternalState } from '../states/snackBarState';
|
||||
|
||||
import { SnackBar } from './SnackBar';
|
||||
|
||||
const SnackBarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 99999999;
|
||||
`;
|
||||
|
||||
const SnackBarMotionContainer = styled(motion.div)`
|
||||
margin-right: ${({ theme }) => theme.spacing(3)};
|
||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
};
|
||||
|
||||
const reducedVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -40,
|
||||
},
|
||||
};
|
||||
|
||||
export function SnackBarProvider({ children }: React.PropsWithChildren) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const [snackBarState, setSnackBarState] = useRecoilState(
|
||||
snackBarInternalState,
|
||||
);
|
||||
|
||||
// Handle snackbar close event
|
||||
const handleSnackBarClose = (id: string) => {
|
||||
setSnackBarState((prevState) => ({
|
||||
...prevState,
|
||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SnackBarContainer>
|
||||
{snackBarState.queue.map((snackBar) => (
|
||||
<SnackBarMotionContainer
|
||||
key={snackBar.id}
|
||||
variants={reducedMotion ? reducedVariants : variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.5 }}
|
||||
layout
|
||||
>
|
||||
<SnackBar
|
||||
{...snackBar}
|
||||
onClose={() => handleSnackBarClose(snackBar.id)}
|
||||
/>
|
||||
</SnackBarMotionContainer>
|
||||
))}
|
||||
</SnackBarContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
front/src/modules/ui/snack-bar/hooks/usePausableTimeout.ts
Normal file
50
front/src/modules/ui/snack-bar/hooks/usePausableTimeout.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export function usePausableTimeout(callback: () => void, delay: number) {
|
||||
const savedCallback = useRef<() => void>(callback);
|
||||
const remainingTime = useRef<number>(delay);
|
||||
const startTime = useRef<number>(Date.now());
|
||||
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const tick = () => {
|
||||
if (savedCallback.current) {
|
||||
savedCallback.current();
|
||||
}
|
||||
};
|
||||
|
||||
const startTimeout = useCallback(() => {
|
||||
startTime.current = Date.now();
|
||||
timeoutId.current = setTimeout(tick, remainingTime.current);
|
||||
}, []);
|
||||
|
||||
// Remember the latest callback
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the timeout loop
|
||||
useEffect(() => {
|
||||
if (delay !== null) {
|
||||
startTimeout();
|
||||
return () => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [delay, startTimeout]);
|
||||
|
||||
const pauseTimeout = () => {
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current);
|
||||
}
|
||||
const elapsedTime = Date.now() - startTime.current;
|
||||
remainingTime.current = remainingTime.current - elapsedTime;
|
||||
};
|
||||
|
||||
const resumeTimeout = () => {
|
||||
startTimeout();
|
||||
};
|
||||
|
||||
return { pauseTimeout, resumeTimeout };
|
||||
}
|
||||
24
front/src/modules/ui/snack-bar/hooks/useSnackBar.ts
Normal file
24
front/src/modules/ui/snack-bar/hooks/useSnackBar.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
SnackBarOptions,
|
||||
snackBarSetQueueState,
|
||||
} from '../states/snackBarState';
|
||||
|
||||
export function useSnackBar() {
|
||||
const setSnackBarQueue = useSetRecoilState(snackBarSetQueueState);
|
||||
|
||||
const enqueueSnackBar = (
|
||||
message: string,
|
||||
options?: Omit<SnackBarOptions, 'message' | 'id'>,
|
||||
) => {
|
||||
setSnackBarQueue({
|
||||
id: uuidv4(),
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
return { enqueueSnackBar };
|
||||
}
|
||||
39
front/src/modules/ui/snack-bar/states/snackBarState.ts
Normal file
39
front/src/modules/ui/snack-bar/states/snackBarState.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { atom, selector } from 'recoil';
|
||||
|
||||
import { SnackbarProps } from '../components/SnackBar';
|
||||
|
||||
export type SnackBarOptions = SnackbarProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SnackBarState = {
|
||||
maxQueue: number;
|
||||
queue: SnackBarOptions[];
|
||||
};
|
||||
|
||||
export const snackBarInternalState = atom<SnackBarState>({
|
||||
key: 'snackBarState',
|
||||
default: {
|
||||
maxQueue: 3,
|
||||
queue: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const snackBarSetQueueState = selector<SnackBarOptions | null>({
|
||||
key: 'snackBarQueueState',
|
||||
get: ({ get }) => null, // We don't care about getting the value
|
||||
set: ({ set }, newValue) =>
|
||||
set(snackBarInternalState, (prev) => {
|
||||
if (prev.queue.length >= prev.maxQueue) {
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue, newValue] as SnackBarOptions[],
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user