Refactor UI folder (#2016)

* Added Overview page

* Revised Getting Started page

* Minor revision

* Edited readme, minor modifications to docs

* Removed sweep.yaml, .devcontainer, .ergomake

* Moved security.md to .github, added contributing.md

* changes as per code review

* updated contributing.md

* fixed broken links & added missing links in doc, improved structure

* fixed link in wsl setup

* fixed server link, added https cloning in yarn-setup

* removed package-lock.json

* added doc card, admonitions

* removed underline from nav buttons

* refactoring modules/ui

* refactoring modules/ui

* Change folder case

* Fix theme location

* Fix case 2

* Fix storybook

---------

Co-authored-by: Nimra Ahmed <nimra1408@gmail.com>
Co-authored-by: Nimra Ahmed <50912134+nimraahmed@users.noreply.github.com>
This commit is contained in:
Charles Bochet
2023-10-14 00:04:29 +02:00
committed by GitHub
parent a35ea5e8f9
commit 258685467b
732 changed files with 1106 additions and 1010 deletions

View File

@ -0,0 +1,159 @@
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;
onClose?: () => void;
};
export const Dialog = ({
title,
message,
buttons = [],
allowDismiss = true,
children,
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();
}
}}
>
<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>
);
};

View File

@ -0,0 +1,49 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { dialogInternalState } from '../states/dialogState';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
import { Dialog } from './Dialog';
export const DialogProvider = ({ children }: React.PropsWithChildren) => {
const [dialogInternal, setDialogInternal] =
useRecoilState(dialogInternalState);
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
// Handle dialog close event
const handleDialogClose = (id: string) => {
setDialogInternal((prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
goBackToPreviousHotkeyScope();
};
useEffect(() => {
if (dialogInternal.queue.length === 0) {
return;
}
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
return (
<>
{children}
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
<Dialog
key={id}
{...{ title, message, buttons, id, children }}
onClose={() => handleDialogClose(id)}
/>
))}
</>
);
};

View File

@ -0,0 +1,17 @@
import { useSetRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { DialogOptions, dialogSetQueueState } from '../states/dialogState';
export const useDialog = () => {
const setDialogQueue = useSetRecoilState(dialogSetQueueState);
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
setDialogQueue({
id: uuidv4(),
...options,
});
};
return { enqueueDialog };
};

View File

@ -0,0 +1,39 @@
import { atom, selector } from 'recoil';
import { DialogProps } from '../components/Dialog';
export type DialogOptions = DialogProps & {
id: string;
};
export type DialogState = {
maxQueue: number;
queue: DialogOptions[];
};
export const dialogInternalState = atom<DialogState>({
key: 'dialog/internal-state',
default: {
maxQueue: 2,
queue: [],
},
});
export const dialogSetQueueState = selector<DialogOptions | null>({
key: 'dialog/queue-state',
get: ({ get: _get }) => null, // We don't care about getting the value
set: ({ set }, newValue) =>
set(dialogInternalState, (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[],
};
}),
});

View File

@ -0,0 +1,3 @@
export enum DialogHotkeyScope {
Dialog = 'dialog',
}

View File

@ -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>
);
};

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';
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>;
};
const StyledBar = styled.div<Pick<ProgressBarProps, 'barHeight'>>`
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,
},
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 (
<StyledBar 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>
);
},
);

View File

@ -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/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],
};

View File

@ -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/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],
};

View File

@ -0,0 +1,179 @@
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;
onClose?: () => void;
}
export const SnackBar = ({
role = 'status',
icon: iconComponent,
message,
allowDismiss = true,
duration = 6000,
variant = 'info',
children,
onClose,
id,
title,
}: 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'}
{...{ 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>
);
};

View File

@ -0,0 +1,93 @@
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 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, setSnackBarInternal] = useRecoilState(
snackBarInternalState,
);
// Handle snackbar close event
const handleSnackBarClose = (id: string) => {
setSnackBarInternal((prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
};
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>
</>
);
};

View File

@ -0,0 +1,50 @@
import { useCallback, useEffect, useRef } from 'react';
export const 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 const 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,40 @@
import { atom, selector } from 'recoil';
import { SnackBarProps } from '../components/SnackBar';
export type SnackBarOptions = SnackBarProps & {
id: string;
};
type SnackBarState = {
maxQueue: number;
queue: SnackBarOptions[];
};
export const snackBarInternalState = atom<SnackBarState>({
key: 'snackBarState',
default: {
maxQueue: 3,
queue: [],
},
});
// TODO: use a recoil callback
export const snackBarSetQueueState = selector<SnackBarOptions | null>({
key: 'snackBarQueueState',
get: ({ 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[],
};
}),
});