Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4845-datepicker

This commit is contained in:
Wahyu Kurniawan 2024-02-23 10:19:10 +07:00
commit e71ed6d76b
No known key found for this signature in database
GPG Key ID: 040A1549143A8E33
19 changed files with 557 additions and 7 deletions

View File

@ -11,6 +11,7 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",

View File

@ -62,7 +62,7 @@ export const buttonTheme = tv(
'text-elements-on-tertiary',
'border',
'border-border-interactive/10',
'bg-transparent',
'bg-controls-tertiary',
'hover:bg-controls-tertiary-hovered',
'hover:border-border-interactive-hovered',
'hover:border-border-interactive-hovered/[0.14]',

View File

@ -9,7 +9,7 @@ import { cloneIcon } from 'utils/cloneIcon';
/**
* Represents the properties of a base button component.
*/
interface ButtonBaseProps {
export interface ButtonBaseProps {
/**
* The optional left icon element for a component.
* @type {ReactNode}

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CheckRoundFilledIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.774 10.1333C16.1237 9.70582 16.0607 9.0758 15.6332 8.72607C15.2058 8.37635 14.5758 8.43935 14.226 8.86679L10.4258 13.5116L9.20711 12.2929C8.81658 11.9024 8.18342 11.9024 7.79289 12.2929C7.40237 12.6834 7.40237 13.3166 7.79289 13.7071L9.79289 15.7071C9.99267 15.9069 10.2676 16.0129 10.5498 15.9988C10.832 15.9847 11.095 15.8519 11.274 15.6333L15.774 10.1333Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const InfoRoundFilledIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM10 11C10 10.4477 10.4477 10 11 10H12C12.5523 10 13 10.4477 13 11V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16V12C10.4477 12 10 11.5523 10 11ZM12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const LoadingIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.0002 1.66669C10.4605 1.66669 10.8336 2.03978 10.8336 2.50002V5.00002C10.8336 5.46026 10.4605 5.83335 10.0002 5.83335C9.54 5.83335 9.1669 5.46026 9.1669 5.00002V2.50002C9.1669 2.03978 9.54 1.66669 10.0002 1.66669ZM15.8928 4.10746C16.2182 4.4329 16.2182 4.96054 15.8928 5.28597L14.125 7.05374C13.7996 7.37918 13.272 7.37918 12.9465 7.05374C12.6211 6.7283 12.6211 6.20067 12.9465 5.87523L14.7143 4.10746C15.0397 3.78203 15.5674 3.78203 15.8928 4.10746ZM4.10768 4.10746C4.43312 3.78203 4.96076 3.78203 5.28619 4.10746L7.05396 5.87523C7.3794 6.20067 7.3794 6.7283 7.05396 7.05374C6.72852 7.37918 6.20088 7.37918 5.87545 7.05374L4.10768 5.28597C3.78224 4.96054 3.78224 4.4329 4.10768 4.10746ZM1.66666 10.0006C1.66666 9.54035 2.03975 9.16725 2.49999 9.16725H4.99999C5.46023 9.16725 5.83332 9.54035 5.83332 10.0006C5.83332 10.4608 5.46023 10.8339 4.99999 10.8339H2.49999C2.03975 10.8339 1.66666 10.4608 1.66666 10.0006ZM14.1667 10.0006C14.1667 9.54035 14.5398 9.16725 15 9.16725H17.5C17.9602 9.16725 18.3333 9.54035 18.3333 10.0006C18.3333 10.4608 17.9602 10.8339 17.5 10.8339H15C14.5398 10.8339 14.1667 10.4608 14.1667 10.0006ZM7.05396 12.9463C7.3794 13.2717 7.3794 13.7994 7.05396 14.1248L5.28619 15.8926C4.96076 16.218 4.43312 16.218 4.10768 15.8926C3.78224 15.5671 3.78224 15.0395 4.10768 14.7141L5.87545 12.9463C6.20088 12.6209 6.72852 12.6209 7.05396 12.9463ZM12.9465 12.9463C13.272 12.6209 13.7996 12.6209 14.125 12.9463L15.8928 14.7141C16.2182 15.0395 16.2182 15.5671 15.8928 15.8926C15.5674 16.218 15.0397 16.218 14.7143 15.8926L12.9465 14.1248C12.6211 13.7994 12.6211 13.2717 12.9465 12.9463ZM10.0002 14.1667C10.4605 14.1667 10.8336 14.5398 10.8336 15V17.5C10.8336 17.9603 10.4605 18.3334 10.0002 18.3334C9.54 18.3334 9.1669 17.9603 9.1669 17.5V15C9.1669 14.5398 9.54 14.1667 10.0002 14.1667Z"
fill="currentColor"
/>
</CustomIcon>
);
};

View File

@ -10,3 +10,6 @@ export * from './SearchIcon';
export * from './CrossIcon';
export * from './GlobeIcon';
export * from './CalendarIcon';
export * from './CheckRoundFilledIcon';
export * from './InfoRoundFilledIcon';
export * from './LoadingIcon';

View File

@ -55,7 +55,7 @@ export const Input = ({
const renderLeftIcon = useMemo(() => {
return (
<div className={iconContainerCls({ class: 'left-0 pl-4' })}>
{cloneIcon(leftIcon, { className: iconCls(), ariaHidden: true })}
{cloneIcon(leftIcon, { className: iconCls(), 'aria-hidden': true })}
</div>
);
}, [cloneIcon, iconCls, iconContainerCls, leftIcon]);
@ -63,7 +63,7 @@ export const Input = ({
const renderRightIcon = useMemo(() => {
return (
<div className={iconContainerCls({ class: 'pr-4 right-0' })}>
{cloneIcon(rightIcon, { className: iconCls(), ariaHidden: true })}
{cloneIcon(rightIcon, { className: iconCls(), 'aria-hidden': true })}
</div>
);
}, [cloneIcon, iconCls, iconContainerCls, rightIcon]);
@ -73,7 +73,7 @@ export const Input = ({
<div className={helperTextCls()}>
{state &&
cloneIcon(<WarningIcon className={helperIconCls()} />, {
ariaHidden: true,
'aria-hidden': true,
})}
<p>{helperText}</p>
</div>

View File

@ -0,0 +1,58 @@
import { VariantProps, tv } from 'tailwind-variants';
export const simpleToastTheme = tv(
{
slots: {
wrapper: [
'flex',
'items-center',
'py-2',
'pl-2',
'pr-1.5',
'gap-2',
'rounded-full',
'mx-auto',
'mt-3',
'w-fit',
'overflow-hidden',
'bg-surface-high-contrast',
'shadow-sm',
],
icon: ['flex', 'items-center', 'justify-center', 'w-5', 'h-5'],
closeIcon: [
'cursor-pointer',
'flex',
'items-center',
'justify-center',
'w-6',
'h-6',
'text-elements-on-high-contrast',
],
title: ['text-sm', 'text-elements-on-high-contrast'],
},
variants: {
variant: {
success: {
icon: ['text-elements-success'],
},
error: {
icon: ['text-elements-danger'],
},
warning: {
icon: ['text-elements-warning'],
},
info: {
icon: ['text-elements-info'],
},
loading: {
icon: ['text-elements-info'],
},
},
},
},
{
responsiveVariants: true,
},
);
export type SimpleToastTheme = VariantProps<typeof simpleToastTheme>;

View File

@ -0,0 +1,94 @@
import React, { useMemo } from 'react';
import * as ToastPrimitive from '@radix-ui/react-toast';
import { ToastProps } from '@radix-ui/react-toast';
import { motion } from 'framer-motion';
import { simpleToastTheme, type SimpleToastTheme } from './SimpleToast.theme';
import {
LoadingIcon,
CheckRoundFilledIcon,
CrossIcon,
InfoRoundFilledIcon,
WarningIcon,
} from 'components/shared/CustomIcon';
import { Button, type ButtonOrLinkProps } from 'components/shared/Button';
import { cloneIcon } from 'utils/cloneIcon';
type CtaProps = ButtonOrLinkProps & {
buttonLabel: string;
};
export interface SimpleToastProps extends ToastProps {
id: string;
title: string;
variant?: SimpleToastTheme['variant'];
cta?: CtaProps[];
onDismiss: (toastId: string) => void;
}
export const SimpleToast = ({
id,
className,
title,
variant = 'success',
cta = [],
onDismiss,
...props
}: SimpleToastProps) => {
const hasCta = cta.length > 0;
const {
wrapper: wrapperCls,
icon: iconCls,
closeIcon: closeIconCls,
title: titleCls,
} = simpleToastTheme({ variant });
const Icon = useMemo(() => {
if (variant === 'success') return <CheckRoundFilledIcon />;
if (variant === 'error') return <WarningIcon />;
if (variant === 'warning') return <WarningIcon />;
if (variant === 'info') return <InfoRoundFilledIcon />;
return <LoadingIcon />; // variant === 'loading'
}, [variant]);
const renderCta = useMemo(() => {
if (!hasCta) return null;
return (
<div className="flex gap-1.5 ml-2">
{cta.map(({ buttonLabel, ...props }, index) => (
<Button key={index} {...props}>
{buttonLabel}
</Button>
))}
</div>
);
}, [cta]);
const renderCloseButton = useMemo(
() => (
<div onClick={() => onDismiss(id)} className={closeIconCls()}>
<CrossIcon className="h-3 w-3" />
</div>
),
[id],
);
return (
<ToastPrimitive.Root {...props} asChild>
<motion.li
animate={{
y: 'var(--radix-toast-swipe-move-y, 0)',
opacity: 1,
}}
className={wrapperCls({ class: className })}
exit={{ y: '100%', opacity: 0 }}
initial={{ y: '100%', opacity: 0 }}
>
{cloneIcon(Icon, { className: iconCls() })}
<ToastPrimitive.Title asChild>
<p className={titleCls()}>{title}</p>
</ToastPrimitive.Title>
{renderCta}
{renderCloseButton}
</motion.li>
</ToastPrimitive.Root>
);
};

View File

@ -0,0 +1,15 @@
import React from 'react';
import {
Provider,
Viewport,
type ToastProviderProps,
} from '@radix-ui/react-toast';
export const ToastProvider = ({ children, ...props }: ToastProviderProps) => {
return (
<Provider {...props}>
{children}
<Viewport className="fixed inset-x-0 bottom-0 px-4 py-10" />
</Provider>
);
};

View File

@ -0,0 +1,25 @@
import React, { ComponentPropsWithoutRef, useMemo } from 'react';
import { Provider, Viewport } from '@radix-ui/react-toast';
import { SimpleToast, SimpleToastProps } from './SimpleToast';
import { useToast } from './useToast';
interface ToasterProps extends ComponentPropsWithoutRef<'div'> {}
export const Toaster = ({}: ToasterProps) => {
const { toasts } = useToast();
const renderToasts = useMemo(
() =>
toasts.map(({ id, ...props }) => (
<SimpleToast key={id} {...(props as SimpleToastProps)} id={id} />
)),
[toasts],
);
return (
<Provider>
{renderToasts}
<Viewport className="z-toast fixed inset-x-0 bottom-0 mx-auto w-fit px-4 pb-10" />
</Provider>
);
};

View File

@ -0,0 +1,2 @@
export * from './Toaster';
export * from './useToast';

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import React from 'react';
import { type ToastProps } from '@radix-ui/react-toast';
import { SimpleToastProps } from './SimpleToast';
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY_DEFAULT = 7000;
type ToasterToast = ToastProps &
SimpleToastProps & {
id: string;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
const genId = () => {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
};
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
duration?: ToasterToast['duration'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string, duration: number) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, duration ?? TOAST_REMOVE_DELAY_DEFAULT);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id
? ({ ...t, ...action.toast } as ToasterToast)
: t,
),
};
case 'DISMISS_TOAST': {
const { toastId, duration = TOAST_REMOVE_DELAY_DEFAULT } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId, duration);
} else {
state.toasts.forEach((_toast) => {
addToRemoveQueue(_toast.id, duration);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(_state: State) => void> = [];
let memoryState: State = { toasts: [] };
const dispatch = (action: Action) => {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
};
const toast = (props: ToasterToast) => {
if (!props.duration) {
props.duration = 2000;
}
const id = genId();
const update = (_props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ..._props, id },
});
const dismiss = () =>
dispatch({ type: 'DISMISS_TOAST', toastId: id, duration: props.duration });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
};
const useToast = () => {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
};
export { toast, useToast };

View File

@ -1,7 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import assert from 'assert';
import { Toaster } from 'react-hot-toast';
import { GQLClient } from 'gql-client';
import { ThemeProvider } from '@material-tailwind/react';
@ -11,6 +10,7 @@ import '@fontsource/inter';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { GQLClientProvider } from './context/GQLClientContext';
import { Toaster } from 'components/shared/Toast';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
@ -26,7 +26,7 @@ root.render(
<ThemeProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster position="bottom-center" />
<Toaster />
</GQLClientProvider>
</ThemeProvider>
</React.StrictMode>,

View File

@ -27,6 +27,7 @@ import {
} from './renders/inlineNotifications';
import { renderInputs } from './renders/input';
import { DatePicker } from 'components/shared/DatePicker';
import { renderToast, renderToastsWithCta } from './renders/toast';
import { renderTooltips } from './renders/tooltip';
const Page = () => {
@ -48,6 +49,16 @@ const Page = () => {
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Toast */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Toasts</h1>
{renderToastsWithCta()}
{renderToast()}
</div>
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Tooltip */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Tooltip</h1>
<div className="flex w-full flex-wrap max-w-[680px] justify-center gap-10">

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
export const renderToastsWithCta = () => {
const { toast, dismiss } = useToast();
return (
<div className="flex gap-10">
{(['success', 'error', 'warning', 'info', 'loading'] as const).map(
(variant, index) => (
<Button
onClick={() =>
toast({
onDismiss: dismiss,
id: `${variant}_${index}`,
title: 'Project created',
cta: [
{
buttonLabel: 'Button',
size: 'xs',
variant: 'tertiary',
},
{
buttonLabel: 'Button',
size: 'xs',
},
],
variant,
})
}
key={`${variant}_${index}`}
>
{variant} with cta
</Button>
),
)}
</div>
);
};
export const renderToast = () => {
const { toast, dismiss } = useToast();
return (
<div className="flex gap-10">
{(['success', 'error', 'warning', 'info', 'loading'] as const).map(
(variant, index) => (
<Button
onClick={() =>
toast({
onDismiss: dismiss,
id: `${variant}_${index}`,
title: 'Project created',
variant,
})
}
key={`${variant}_${index}`}
>
{variant}
</Button>
),
)}
</div>
);
};

View File

@ -161,6 +161,9 @@ export default withMT({
3.25: '0.8125rem',
3.5: '0.875rem',
},
zIndex: {
toast: '9999',
},
},
},
plugins: [],

View File

@ -3548,6 +3548,23 @@
"@radix-ui/react-roving-focus" "1.0.4"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-toast@^1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6"
integrity sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-tooltip@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"