feat: snack-bar component (#626)

* feat: SnackBarProvider and queuing

* feat: use snack bar on onboarding errors

* feat: workspace copy use snackBar

* fix: remove magic number
This commit is contained in:
Jérémy M
2023-07-14 06:27:09 +02:00
committed by GitHub
parent 551c3b5e60
commit 03364330d1
13 changed files with 549 additions and 64 deletions

View File

@ -11,6 +11,7 @@ import '@emotion/react';
import { ApolloProvider } from './providers/apollo/ApolloProvider';
import { ClientConfigProvider } from './providers/client-config/ClientConfigProvider';
import { SnackBarProvider } from './providers/snack-bar/SnackBarProvider';
import { AppThemeProvider } from './providers/theme/AppThemeProvider';
import { UserProvider } from './providers/user/UserProvider';
import { App } from './App';
@ -29,11 +30,15 @@ root.render(
<StrictMode>
<BrowserRouter>
<UserProvider>
<ClientConfigProvider>
<HotkeysProvider initiallyActiveScopes={INITIAL_HOTKEYS_SCOPES}>
<App />
</HotkeysProvider>
</ClientConfigProvider>
<SnackBarProvider>
<ClientConfigProvider>
<HotkeysProvider
initiallyActiveScopes={INITIAL_HOTKEYS_SCOPES}
>
<App />
</HotkeysProvider>
</ClientConfigProvider>
</SnackBarProvider>
</UserProvider>
</BrowserRouter>
</StrictMode>

View File

@ -0,0 +1,182 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { usePausableTimeout } from '@/snack-bar/hooks/usePausableTimeout';
import { IconAlertTriangle, IconX } from '@/ui/icons';
import { rgba } from '@/ui/themes/colors';
import {
ProgressBar,
ProgressBarControls,
} from '../../ui/components/progress-bar/ProgressBar';
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>
);
}

View 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 };
}

View 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 };
}

View File

@ -0,0 +1,39 @@
import { atom, selector } from 'recoil';
import { SnackbarProps } from '@/snack-bar/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[],
};
}),
});

View File

@ -0,0 +1,101 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimationControls, motion, useAnimation } from 'framer-motion';
const Bar = styled.div<Pick<ProgressBarProps, 'barHeight'>>`
height: ${({ barHeight }) => barHeight}px;
overflow: hidden;
width: 100%;
`;
const BarFilling = styled(motion.div)`
height: 100%;
width: 100%;
`;
export type ProgressBarProps = {
duration?: number;
delay?: number;
easing?: string;
barHeight?: number;
barColor?: string;
autoStart?: boolean;
};
export type ProgressBarControls = AnimationControls & {
start: () => Promise<any>;
pause: () => Promise<any>;
};
export const ProgressBar = forwardRef<ProgressBarControls, ProgressBarProps>(
(
{
duration = 3,
delay = 0,
easing = 'easeInOut',
barHeight = 24,
barColor,
autoStart = true,
},
ref,
) => {
const theme = useTheme();
const controls = useAnimation();
const startTimestamp = useRef<number>(0);
const remainingTime = useRef<number>(duration);
const start = useCallback(async () => {
startTimestamp.current = Date.now();
return controls.start({
scaleX: 0,
transition: {
duration: remainingTime.current / 1000, // convert ms to s for framer-motion
delay: delay / 1000, // likewise
ease: easing,
},
});
}, [controls, delay, easing]);
useImperativeHandle(ref, () => ({
...controls,
start: async () => {
return start();
},
pause: async () => {
const elapsed = Date.now() - startTimestamp.current;
remainingTime.current = remainingTime.current - elapsed;
return controls.stop();
},
}));
useEffect(() => {
if (autoStart) {
start();
}
}, [controls, delay, duration, easing, autoStart, start]);
return (
<Bar barHeight={barHeight}>
<BarFilling
style={{
originX: 0,
// Seems like custom props are not well handled by react when used with framer-motion and emotion styled
backgroundColor: barColor ?? theme.color.gray80,
}}
initial={{ scaleX: 1 }}
animate={controls}
exit={{ scaleX: 0 }}
/>
</Bar>
);
},
);

View File

@ -38,3 +38,5 @@ export { IconTimelineEvent } from '@tabler/icons-react';
export { IconAlertCircle } from '@tabler/icons-react';
export { IconEye } from '@tabler/icons-react';
export { IconEyeOff } from '@tabler/icons-react';
export { IconAlertTriangle } from '@tabler/icons-react';
export { IconCopy } from '@tabler/icons-react';

View File

@ -13,6 +13,20 @@ const common = {
icon: icon,
text: text,
blur: blur,
snackBar: {
success: {
background: '#16A26B',
color: '#D0F8E9',
},
error: {
background: '#B43232',
color: '#FED8D8',
},
info: {
background: color.gray80,
color: color.gray0,
},
},
spacing: (multiplicator: number) => `${multiplicator * 4}px`,
table: {
horizontalCellMargin: '8px',

View File

@ -1,10 +1,10 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { Button } from '@/ui/components/buttons/Button';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { IconCheck, IconLink } from '@/ui/icons';
import { IconCopy, IconLink } from '@/ui/icons';
const StyledContainer = styled.div`
align-items: center;
@ -23,24 +23,24 @@ type OwnProps = {
export function WorkspaceInviteLink({ inviteLink }: OwnProps) {
const theme = useTheme();
const [isCopied, setIsCopied] = useState(false);
const { enqueueSnackBar } = useSnackBar();
return (
<StyledContainer>
<StyledLinkContainer>
<TextInput value={inviteLink} disabled fullWidth />
</StyledLinkContainer>
<Button
icon={
isCopied ? (
<IconCheck size={theme.icon.size.md} />
) : (
<IconLink size={theme.icon.size.md} />
)
}
icon={<IconLink size={theme.icon.size.md} />}
variant="primary"
title={isCopied ? 'Copied' : 'Copy link'}
title="Copy link"
onClick={() => {
setIsCopied(true);
enqueueSnackBar('Link copied to clipboard', {
variant: 'success',
icon: <IconCopy size={theme.icon.size.md} />,
duration: 2000,
});
navigator.clipboard.writeText(inviteLink);
}}
/>

View File

@ -16,6 +16,7 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
@ -47,10 +48,6 @@ const StyledComboInputContainer = styled.div`
}
`;
const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
firstName: Yup.string().required('First name can not be empty'),
@ -64,6 +61,8 @@ export function CreateProfile() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
const [currentUser] = useRecoilState(currentUserState);
const [updateUser] = useUpdateUserMutation();
@ -73,7 +72,6 @@ export function CreateProfile() {
control,
handleSubmit,
formState: { isValid, isSubmitting },
setError,
getValues,
} = useForm<Form>({
mode: 'onChange',
@ -118,10 +116,12 @@ export function CreateProfile() {
navigate('/');
} catch (error: any) {
setError('root', { message: error?.message });
enqueueSnackBar(error?.message, {
variant: 'error',
});
}
},
[currentUser?.id, navigate, setError, updateUser],
[currentUser?.id, enqueueSnackBar, navigate, updateUser],
);
useScopedHotkeys(
@ -202,14 +202,6 @@ export function CreateProfile() {
fullWidth
/>
</StyledButtonContainer>
{/* Will be replaced by error snack bar */}
<Controller
name="firstName"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</>
);
}

View File

@ -13,6 +13,7 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
@ -36,10 +37,6 @@ const StyledButtonContainer = styled.div`
width: 200px;
`;
const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
name: Yup.string().required('Name can not be empty'),
@ -52,6 +49,8 @@ export function CreateWorkspace() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
// Form
@ -59,7 +58,6 @@ export function CreateWorkspace() {
control,
handleSubmit,
formState: { isValid, isSubmitting },
setError,
getValues,
} = useForm<Form>({
mode: 'onChange',
@ -90,10 +88,12 @@ export function CreateWorkspace() {
navigate('/auth/create/profile');
} catch (error: any) {
setError('root', { message: error?.message });
enqueueSnackBar(error?.message, {
variant: 'error',
});
}
},
[navigate, setError, updateWorkspace],
[enqueueSnackBar, navigate, updateWorkspace],
);
useScopedHotkeys(
@ -156,14 +156,6 @@ export function CreateWorkspace() {
fullWidth
/>
</StyledButtonContainer>
{/* Will be replaced by error snack bar */}
<Controller
name="name"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</>
);
}

View File

@ -15,6 +15,7 @@ import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
@ -47,10 +48,6 @@ const StyledButtonContainer = styled.div`
width: 200px;
`;
const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
exist: Yup.boolean().required(),
@ -69,6 +66,8 @@ type Form = Yup.InferType<typeof validationSchema>;
export function PasswordLogin() {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const [isDemoMode] = useRecoilState(isDemoModeState);
const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState);
const [showErrors, setShowErrors] = useState(false);
@ -88,7 +87,6 @@ export function PasswordLogin() {
control,
handleSubmit,
formState: { isSubmitting },
setError,
watch,
getValues,
} = useForm<Form>({
@ -114,16 +112,19 @@ export function PasswordLogin() {
}
navigate('/auth/create/workspace');
} catch (err: any) {
setError('root', { message: err?.message });
console.log('err', err);
enqueueSnackBar(err?.message, {
variant: 'error',
});
}
},
[
login,
checkUserExistsData?.checkUserExists.exists,
navigate,
setError,
login,
signUp,
workspaceInviteHash,
checkUserExistsData,
enqueueSnackBar,
],
);
useScopedHotkeys(
@ -200,14 +201,6 @@ export function PasswordLogin() {
fullWidth
/>
</StyledButtonContainer>
{/* Will be replaced by error snack bar */}
<Controller
name="exist"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</StyledForm>
</>
);

View File

@ -0,0 +1,91 @@
import styled from '@emotion/styled';
import { motion, useReducedMotion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { SnackBar } from '@/snack-bar/components/SnackBar';
import { snackBarInternalState } from '../../modules/snack-bar/states/snackBarState';
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>
</>
);
}