Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -0,0 +1,162 @@
|
||||
import { useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||
|
||||
const StyledDialogOverlay = styled(motion.div)`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.overlay};
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
const StyledDialogContainer = styled(motion.div)`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 320px;
|
||||
padding: 2em;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledDialogTitle = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledDialogMessage = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledDialogButton = styled(Button)`
|
||||
justify-content: center;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export type DialogButtonOptions = Omit<
|
||||
React.ComponentProps<typeof Button>,
|
||||
'fullWidth'
|
||||
> & {
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
|
||||
) => void;
|
||||
role?: 'confirm';
|
||||
};
|
||||
|
||||
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
|
||||
title?: string;
|
||||
message?: string;
|
||||
buttons?: DialogButtonOptions[];
|
||||
allowDismiss?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Dialog = ({
|
||||
title,
|
||||
message,
|
||||
buttons = [],
|
||||
allowDismiss = true,
|
||||
children,
|
||||
className,
|
||||
onClose,
|
||||
id,
|
||||
}: DialogProps) => {
|
||||
const closeSnackbar = useCallback(() => {
|
||||
onClose && onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const dialogVariants = {
|
||||
open: { opacity: 1 },
|
||||
closed: { opacity: 0 },
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
open: { y: 0 },
|
||||
closed: { y: '50vh' },
|
||||
};
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Enter,
|
||||
(event: KeyboardEvent) => {
|
||||
const confirmButton = buttons.find((button) => button.role === 'confirm');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (confirmButton) {
|
||||
confirmButton?.onClick?.(event);
|
||||
closeSnackbar();
|
||||
}
|
||||
},
|
||||
DialogHotkeyScope.Dialog,
|
||||
[],
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
(event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
closeSnackbar();
|
||||
},
|
||||
DialogHotkeyScope.Dialog,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDialogOverlay
|
||||
variants={dialogVariants}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
onClick={(e) => {
|
||||
if (allowDismiss) {
|
||||
e.stopPropagation();
|
||||
closeSnackbar();
|
||||
}
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<StyledDialogContainer
|
||||
variants={containerVariants}
|
||||
transition={{ damping: 15, stiffness: 100 }}
|
||||
id={id}
|
||||
>
|
||||
{title && <StyledDialogTitle>{title}</StyledDialogTitle>}
|
||||
{message && <StyledDialogMessage>{message}</StyledDialogMessage>}
|
||||
{children}
|
||||
{buttons.map(({ accent, onClick, role, title: key, variant }) => (
|
||||
<StyledDialogButton
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
closeSnackbar();
|
||||
}}
|
||||
fullWidth={true}
|
||||
variant={variant ?? 'secondary'}
|
||||
{...{ accent, key, role }}
|
||||
/>
|
||||
))}
|
||||
</StyledDialogContainer>
|
||||
</StyledDialogOverlay>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
|
||||
import { useDialogManager } from '../hooks/useDialogManager';
|
||||
|
||||
import { Dialog } from './Dialog';
|
||||
import { DialogManagerEffect } from './DialogManagerEffect';
|
||||
|
||||
export const DialogManager = ({ children }: React.PropsWithChildren) => {
|
||||
const { dialogInternal } = useDialogManagerScopedStates();
|
||||
const { closeDialog } = useDialogManager();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogManagerEffect />
|
||||
{children}
|
||||
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
|
||||
<Dialog
|
||||
key={id}
|
||||
{...{ title, message, buttons, id, children }}
|
||||
onClose={() => closeDialog(id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
|
||||
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
|
||||
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
|
||||
|
||||
export const DialogManagerEffect = () => {
|
||||
const { dialogInternal } = useDialogManagerScopedStates();
|
||||
|
||||
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogInternal.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
|
||||
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from '../../scopes/scope-internal-context/DialogManagerScopeInternalContext';
|
||||
import { dialogInternalScopedState } from '../../states/dialogInternalScopedState';
|
||||
|
||||
type useDialogManagerScopedStatesProps = {
|
||||
dialogManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useDialogManagerScopedStates = (
|
||||
props?: useDialogManagerScopedStatesProps,
|
||||
) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
DialogManagerScopeInternalContext,
|
||||
props?.dialogManagerScopeId,
|
||||
);
|
||||
|
||||
const [dialogInternal, setDialogInternal] = useRecoilScopedStateV2(
|
||||
dialogInternalScopedState,
|
||||
scopeId,
|
||||
);
|
||||
|
||||
return { dialogInternal, setDialogInternal };
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext';
|
||||
import { dialogInternalScopedState } from '../states/dialogInternalScopedState';
|
||||
import { DialogOptions } from '../types/DialogOptions';
|
||||
|
||||
type useDialogManagerProps = {
|
||||
dialogManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useDialogManager = (props?: useDialogManagerProps) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
DialogManagerScopeInternalContext,
|
||||
props?.dialogManagerScopeId,
|
||||
);
|
||||
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
const closeDialog = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(id: string) => {
|
||||
set(dialogInternalScopedState({ scopeId: scopeId }), (prevState) => ({
|
||||
...prevState,
|
||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||
}));
|
||||
goBackToPreviousHotkeyScope();
|
||||
},
|
||||
[goBackToPreviousHotkeyScope, scopeId],
|
||||
);
|
||||
|
||||
const setDialogQueue = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(newValue) =>
|
||||
set(dialogInternalScopedState({ scopeId: scopeId }), (prev) => {
|
||||
if (prev.queue.length >= prev.maxQueue) {
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue.slice(1), newValue] as DialogOptions[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
queue: [...prev.queue, newValue] as DialogOptions[],
|
||||
};
|
||||
}),
|
||||
[scopeId],
|
||||
);
|
||||
|
||||
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
|
||||
setDialogQueue({
|
||||
id: v4(),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
return { closeDialog, enqueueDialog };
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { DialogManagerScopeInternalContext } from './scope-internal-context/DialogManagerScopeInternalContext';
|
||||
|
||||
type DialogManagerScopeProps = {
|
||||
children: ReactNode;
|
||||
dialogManagerScopeId: string;
|
||||
};
|
||||
|
||||
export const DialogManagerScope = ({
|
||||
children,
|
||||
dialogManagerScopeId,
|
||||
}: DialogManagerScopeProps) => {
|
||||
return (
|
||||
<DialogManagerScopeInternalContext.Provider
|
||||
value={{
|
||||
scopeId: dialogManagerScopeId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogManagerScopeInternalContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
|
||||
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
|
||||
|
||||
type DialogManagerScopeInternalContextProps = ScopedStateKey;
|
||||
|
||||
export const DialogManagerScopeInternalContext =
|
||||
createScopeInternalContext<DialogManagerScopeInternalContextProps>();
|
||||
@ -0,0 +1,16 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
import { DialogOptions } from '../types/DialogOptions';
|
||||
|
||||
type DialogState = {
|
||||
maxQueue: number;
|
||||
queue: DialogOptions[];
|
||||
};
|
||||
|
||||
export const dialogInternalScopedState = createScopedState<DialogState>({
|
||||
key: 'dialog/internal-state',
|
||||
defaultValue: {
|
||||
maxQueue: 2,
|
||||
queue: [],
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
export enum DialogHotkeyScope {
|
||||
Dialog = 'dialog',
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { DialogProps } from '../components/Dialog';
|
||||
|
||||
export type DialogOptions = DialogProps & {
|
||||
id: string;
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { motion, useAnimation } from 'framer-motion';
|
||||
|
||||
interface CircularProgressBarProps {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
barColor?: string;
|
||||
}
|
||||
|
||||
export const CircularProgressBar = ({
|
||||
size = 50,
|
||||
barWidth = 5,
|
||||
barColor = 'currentColor',
|
||||
}: CircularProgressBarProps) => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const circumference = useMemo(
|
||||
() => 2 * Math.PI * (size / 2 - barWidth),
|
||||
[size, barWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const animateIndeterminate = async () => {
|
||||
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
|
||||
|
||||
// Adjusted sequence based on baseSegment
|
||||
const dashSequences = [
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
|
||||
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||
`${baseSegment} ${circumference - baseSegment}`,
|
||||
];
|
||||
|
||||
await controls.start({
|
||||
strokeDasharray: dashSequences,
|
||||
rotate: [0, 720],
|
||||
transition: {
|
||||
strokeDasharray: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
rotate: {
|
||||
duration: 2,
|
||||
ease: 'linear',
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
animateIndeterminate();
|
||||
}, [circumference, controls]);
|
||||
|
||||
return (
|
||||
<motion.svg width={size} height={size} animate={controls}>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={size / 2 - barWidth}
|
||||
fill="none"
|
||||
stroke={barColor}
|
||||
strokeWidth={barWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
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';
|
||||
|
||||
export type ProgressBarProps = {
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
easing?: string;
|
||||
barHeight?: number;
|
||||
barColor?: string;
|
||||
autoStart?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type StyledBarProps = {
|
||||
barHeight?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ProgressBarControls = AnimationControls & {
|
||||
start: () => Promise<any>;
|
||||
pause: () => Promise<any>;
|
||||
};
|
||||
|
||||
const StyledBar = styled.div<StyledBarProps>`
|
||||
height: ${({ barHeight }) => barHeight}px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledBarFilling = styled(motion.div)`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const ProgressBar = forwardRef<ProgressBarControls, ProgressBarProps>(
|
||||
(
|
||||
{
|
||||
duration = 3,
|
||||
delay = 0,
|
||||
easing = 'easeInOut',
|
||||
barHeight = 24,
|
||||
barColor,
|
||||
autoStart = true,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const controls = useAnimation();
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const startTimestamp = useRef<number>(0);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
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 (
|
||||
<StyledBar className={className} barHeight={barHeight}>
|
||||
<StyledBarFilling
|
||||
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 }}
|
||||
/>
|
||||
</StyledBar>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,56 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { CircularProgressBar } from '../CircularProgressBar';
|
||||
|
||||
const meta: Meta<typeof CircularProgressBar> = {
|
||||
title: 'UI/Feedback/CircularProgressBar/CircularProgressBar',
|
||||
component: CircularProgressBar,
|
||||
args: {
|
||||
size: 50,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CircularProgressBar>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof CircularProgressBar> = {
|
||||
argTypes: {},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'barColor',
|
||||
values: [undefined, 'red'],
|
||||
props: (barColor: string) => ({ barColor }),
|
||||
labels: (color: string) => `Segment Color: ${color ?? 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'barWidth',
|
||||
values: [undefined, 5, 10],
|
||||
props: (barWidth: number) => ({ barWidth }),
|
||||
labels: (width: number) =>
|
||||
`Stroke Width: ${width ? width + ' px' : 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
values: [undefined, 80, 30],
|
||||
props: (size: number) => ({ size }),
|
||||
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,63 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
import { CatalogStory } from '~/testing/types';
|
||||
|
||||
import { ProgressBar } from '../ProgressBar';
|
||||
|
||||
const meta: Meta<typeof ProgressBar> = {
|
||||
title: 'UI/Feedback/ProgressBar/ProgressBar',
|
||||
component: ProgressBar,
|
||||
args: {
|
||||
duration: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ProgressBar>;
|
||||
const args = {};
|
||||
const defaultArgTypes = {
|
||||
control: false,
|
||||
};
|
||||
export const Default: Story = {
|
||||
args,
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof ProgressBar> = {
|
||||
args: {
|
||||
...args,
|
||||
},
|
||||
argTypes: {
|
||||
barHeight: defaultArgTypes,
|
||||
barColor: defaultArgTypes,
|
||||
autoStart: defaultArgTypes,
|
||||
},
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'animation',
|
||||
values: [true, false],
|
||||
props: (autoStart: string) => ({ autoStart: Boolean(autoStart) }),
|
||||
labels: (autoStart: string) => `AutoStart: ${autoStart}`,
|
||||
},
|
||||
{
|
||||
name: 'colors',
|
||||
values: [undefined, 'blue'],
|
||||
props: (barColor: string) => ({ barColor }),
|
||||
labels: (color: string) => `Color: ${color ?? 'default'}`,
|
||||
},
|
||||
{
|
||||
name: 'sizes',
|
||||
values: [undefined, 10],
|
||||
props: (barHeight: number) => ({ barHeight }),
|
||||
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -0,0 +1,183 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconAlertTriangle, IconX } from '@/ui/display/icon';
|
||||
import {
|
||||
ProgressBar,
|
||||
ProgressBarControls,
|
||||
} from '@/ui/feedback/progress-bar/components/ProgressBar';
|
||||
import { rgba } from '@/ui/theme/constants/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.grayScale.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 StyledProgressBarContainer = styled.div`
|
||||
height: 5px;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
const StyledCloseButton = 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.grayScale.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.grayScale.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;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const SnackBar = ({
|
||||
role = 'status',
|
||||
icon: iconComponent,
|
||||
message,
|
||||
allowDismiss = true,
|
||||
duration = 6000,
|
||||
variant = 'info',
|
||||
children,
|
||||
onClose,
|
||||
id,
|
||||
title,
|
||||
className,
|
||||
}: SnackBarProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
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
|
||||
className={className}
|
||||
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||
{...{ id, onMouseEnter, onMouseLeave, role, title, variant }}
|
||||
>
|
||||
<StyledProgressBarContainer>
|
||||
<ProgressBar
|
||||
ref={progressBarRef}
|
||||
barHeight={5}
|
||||
barColor={rgba(theme.grayScale.gray0, 0.3)}
|
||||
duration={duration}
|
||||
/>
|
||||
</StyledProgressBarContainer>
|
||||
{icon && <StyledIconContainer>{icon}</StyledIconContainer>}
|
||||
{children ? children : message}
|
||||
{allowDismiss && (
|
||||
<StyledCloseButton variant={variant} onClick={closeSnackbar}>
|
||||
<IconX aria-label="Close" size={theme.icon.size.md} />
|
||||
</StyledCloseButton>
|
||||
)}
|
||||
</StyledMotionContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,83 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
|
||||
import { useSnackBarManagerScopedStates } from '@/ui/feedback/snack-bar-manager/hooks/internal/useSnackBarManagerScopedStates';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
import { SnackBar } from './SnackBar';
|
||||
const StyledSnackBarContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 99999999;
|
||||
`;
|
||||
|
||||
const StyledSnackBarMotionContainer = 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 const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const { snackBarInternal } = useSnackBarManagerScopedStates();
|
||||
const { handleSnackBarClose } = useSnackBar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<StyledSnackBarContainer>
|
||||
{snackBarInternal.queue.map(
|
||||
({ duration, icon, id, message, title, variant }) => (
|
||||
<StyledSnackBarMotionContainer
|
||||
key={id}
|
||||
variants={reducedMotion ? reducedVariants : variants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.5 }}
|
||||
layout
|
||||
>
|
||||
<SnackBar
|
||||
{...{ duration, icon, message, title, variant }}
|
||||
onClose={() => handleSnackBarClose(id)}
|
||||
/>
|
||||
</StyledSnackBarMotionContainer>
|
||||
),
|
||||
)}
|
||||
</StyledSnackBarContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
import { snackBarInternalScopedState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
|
||||
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
type useSnackBarManagerScopedStatesProps = {
|
||||
snackBarManagerScopeId?: string;
|
||||
};
|
||||
|
||||
export const useSnackBarManagerScopedStates = (
|
||||
props?: useSnackBarManagerScopedStatesProps,
|
||||
) => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
SnackBarManagerScopeInternalContext,
|
||||
props?.snackBarManagerScopeId,
|
||||
);
|
||||
|
||||
const [snackBarInternal, setSnackBarInternal] = useRecoilScopedStateV2(
|
||||
snackBarInternalScopedState,
|
||||
scopeId,
|
||||
);
|
||||
|
||||
return { snackBarInternal, setSnackBarInternal };
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const usePausableTimeout = (callback: () => void, delay: number) => {
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const savedCallback = useRef<() => void>(callback);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const remainingTime = useRef<number>(delay);
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const startTime = useRef<number>(Date.now());
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
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 };
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
import {
|
||||
snackBarInternalScopedState,
|
||||
SnackBarOptions,
|
||||
} from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
|
||||
export const useSnackBar = () => {
|
||||
const scopeId = useAvailableScopeIdOrThrow(
|
||||
SnackBarManagerScopeInternalContext,
|
||||
);
|
||||
|
||||
const handleSnackBarClose = useRecoilCallback(({ set }) => (id: string) => {
|
||||
set(snackBarInternalScopedState({ scopeId }), (prevState) => ({
|
||||
...prevState,
|
||||
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||
}));
|
||||
});
|
||||
|
||||
const setSnackBarQueue = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(newValue) =>
|
||||
set(snackBarInternalScopedState({ scopeId }), (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[],
|
||||
};
|
||||
}),
|
||||
[scopeId],
|
||||
);
|
||||
|
||||
const enqueueSnackBar = useCallback(
|
||||
(message: string, options?: Omit<SnackBarOptions, 'message' | 'id'>) => {
|
||||
setSnackBarQueue({
|
||||
id: uuidv4(),
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSnackBarQueue],
|
||||
);
|
||||
|
||||
return { handleSnackBarClose, enqueueSnackBar };
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { SnackBarManagerScopeInternalContext } from './scope-internal-context/SnackBarManagerScopeInternalContext';
|
||||
|
||||
type SnackBarProviderScopeProps = {
|
||||
children: ReactNode;
|
||||
snackBarManagerScopeId: string;
|
||||
};
|
||||
|
||||
export const SnackBarProviderScope = ({
|
||||
children,
|
||||
snackBarManagerScopeId,
|
||||
}: SnackBarProviderScopeProps) => {
|
||||
return (
|
||||
<SnackBarManagerScopeInternalContext.Provider
|
||||
value={{
|
||||
scopeId: snackBarManagerScopeId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SnackBarManagerScopeInternalContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
|
||||
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
|
||||
|
||||
type SnackBarManagerScopeInternalContextProps = ScopedStateKey;
|
||||
|
||||
export const SnackBarManagerScopeInternalContext =
|
||||
createScopeInternalContext<SnackBarManagerScopeInternalContextProps>();
|
||||
@ -0,0 +1,20 @@
|
||||
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
|
||||
|
||||
import { SnackBarProps } from '../components/SnackBar';
|
||||
|
||||
export type SnackBarOptions = SnackBarProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type SnackBarState = {
|
||||
maxQueue: number;
|
||||
queue: SnackBarOptions[];
|
||||
};
|
||||
|
||||
export const snackBarInternalScopedState = createScopedState<SnackBarState>({
|
||||
key: 'snackBarState',
|
||||
defaultValue: {
|
||||
maxQueue: 3,
|
||||
queue: [],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user