Compare commits

...
Author SHA1 Message Date
Wahyu Kurniawan e71ed6d76b Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4845-datepicker 2024-02-23 10:19:10 +07:00
Andre Hadianto 3718e260f5 Merge pull request #95 from snowball-tools/andrehadianto/T-4869-toast
[T-4869] implement toast component
2024-02-22 23:48:45 +08:00
Andre Hadianto 42bf1534fb Merge branch 'andrehadianto/design-system-components' into andrehadianto/T-4869-toast 2024-02-22 23:42:47 +08:00
Wahyu Kurniawan 9fb0f6077a Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4845-datepicker 2024-02-22 17:44:51 +07:00
Wahyu Kurniawan 7d1810ebd9 [T-4866: feat] Switch component (#92)
* ️ feat: create switch component

* 📝 docs: add switch to the example page

* 🔧 chore: install `@radix-ui/react-switch`

* 🎨 style: add inset shadow

* 🎨 style: addjust input outline when error and focus
2024-02-22 17:42:13 +07:00
Wahyu Kurniawan 2117f3e2a7 Merge branch 'andrehadianto/design-system-components' of https://github.com/snowball-tools/snowballtools-base into ayungavis/T-4845-datepicker 2024-02-22 17:40:45 +07:00
Andre H 0e7343da6f 🔧 chore: gqlclientprovider import 2024-02-22 17:11:03 +07:00
Andre H 8952393b31 🔧 chore: remove not working swipe down gesture 2024-02-22 17:08:05 +07:00
Andre H 32918b7930 🔧 chore: index import 2024-02-22 17:07:15 +07:00
Wahyu Kurniawan fad9d6e90b 🔧 chore: install @radix-ui/react-popover 2024-02-22 16:42:45 +07:00
Wahyu Kurniawan 64379ec9c6 📝 docs: add date picker to the example page 2024-02-22 16:42:25 +07:00
Wahyu Kurniawan a8fdd4bb53 ️ feat: create date picker component 2024-02-22 16:42:05 +07:00
Wahyu Kurniawan 779b660631 ️ feat: create calendar icon for date picker 2024-02-22 16:41:42 +07:00
Andre H 200ea3ca7b 🐛 fix: aria-hidden naming on input 2024-02-22 16:14:27 +07:00
Andre H ba87c6f22a 🔧 chore: replace react-hot-toast with custom toast 2024-02-22 16:11:37 +07:00
Andre H 4d646e1ac0 🔧 chore: extend to buttonOrLinkProps 2024-02-22 15:03:21 +07:00
Andre H 5be29a9745 🔧 chore: add id to allow singular toast deletion 2024-02-22 14:59:12 +07:00
Andre H fffc370d40 🔧 chore: rework usememo logic 2024-02-22 14:58:57 +07:00
Andre H 1cd60d84dd 🔧 chore: use framer-motion 2024-02-22 14:22:29 +07:00
Andre H c8a153ad27 🔧 chore: usememo dependencies 2024-02-22 14:20:26 +07:00
Andre H bb7a88f7e9 🔧 chore: add toast render page 2024-02-22 14:17:02 +07:00
Andre H 675112079c ️ feat: implement toast component 2024-02-22 14:16:47 +07:00
Andre H cb928c3360 🔧 chore: use buttonbaseprops, duration 2000 default 2024-02-22 14:16:21 +07:00
Andre H 5836779403 🔧 chore: export buttonBaseProps 2024-02-22 14:13:47 +07:00
Andre H 028dd35db1 🔧 chore: add icons 2024-02-22 14:13:27 +07:00
Andre H ef9f4df28a 🔧 chore: ghost button is white, not transparent 2024-02-22 14:13:10 +07:00
Andre H 267b52a352 Merge remote-tracking branch 'origin/andrehadianto/design-system-components' into andrehadianto/T-4869-toast 2024-02-22 11:49:11 +07:00
Andre H fa96b6e734 🔧 chore: add to components render page 2024-02-22 11:46:39 +07:00
Andre H 22c581dd33 🔧 chore: add icons 2024-02-22 11:46:17 +07:00
Andre H d3013719e6 🔧 chore: wip simple toast render 2024-02-22 11:45:55 +07:00
Andre H 98a4d7be07 ️ feat: implement toast contexts 2024-02-22 11:45:35 +07:00
Andre H 3928e2610f 🔧 chore: add zIndex toast token 2024-02-22 11:45:01 +07:00
Andre H 075cc05db5 ⚒️ build: add @radix-ui/react-toast 2024-02-22 11:43:31 +07:00
27 changed files with 1012 additions and 8 deletions
+3
View File
@@ -7,8 +7,11 @@
"@material-tailwind/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.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",
@@ -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]',
@@ -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}
@@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const CalendarIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5 0C5.55228 0 6 0.447715 6 1V2H12V1C12 0.447715 12.4477 0 13 0C13.5523 0 14 0.447715 14 1V2H16C17.1046 2 18 2.89543 18 4V17C18 18.1046 17.1046 19 16 19H2C0.89543 19 0 18.1046 0 17V4C0 2.89543 0.895431 2 2 2H4V1C4 0.447715 4.44772 0 5 0ZM2 9V17H16V9H2Z"
fill="currentColor"
/>
</CustomIcon>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -9,3 +9,7 @@ export * from './WarningIcon';
export * from './SearchIcon';
export * from './CrossIcon';
export * from './GlobeIcon';
export * from './CalendarIcon';
export * from './CheckRoundFilledIcon';
export * from './InfoRoundFilledIcon';
export * from './LoadingIcon';
@@ -0,0 +1,9 @@
import { VariantProps, tv } from 'tailwind-variants';
export const datePickerTheme = tv({
slots: {
input: [],
},
});
export type DatePickerTheme = VariantProps<typeof datePickerTheme>;
@@ -0,0 +1,100 @@
import React, { useCallback, useState } from 'react';
import { Input, InputProps } from 'components/shared/Input';
import * as Popover from '@radix-ui/react-popover';
import { datePickerTheme } from './DatePicker.theme';
import { Calendar, CalendarProps } from 'components/shared/Calendar';
import { CalendarIcon } from 'components/shared/CustomIcon';
import { Value } from 'react-calendar/dist/cjs/shared/types';
import { format } from 'date-fns';
export interface DatePickerProps
extends Omit<InputProps, 'onChange' | 'value'> {
/**
* The props for the calendar component.
*/
calendarProps?: CalendarProps;
/**
* Optional callback function that is called when the value of the input changes.
* @param {string} value - The new value of the input.
* @returns None
*/
onChange?: (value: Value) => void;
/**
* The value of the input.
*/
value?: Value;
/**
* Whether to allow the selection of a date range.
*/
selectRange?: boolean;
}
/**
* A date picker component that allows users to select a date from a calendar.
* @param {DatePickerProps} props - The props for the date picker component.
* @returns The rendered date picker component.
*/
export const DatePicker = ({
className,
calendarProps,
value,
onChange,
selectRange = false,
...props
}: DatePickerProps) => {
const { input } = datePickerTheme();
const [open, setOpen] = useState(false);
/**
* Renders the value of the date based on the current state of `props.value`.
* @returns {string | undefined} - The formatted date value or `undefined` if `props.value` is falsy.
*/
const renderValue = useCallback(() => {
if (!value) return undefined;
if (Array.isArray(value)) {
return value
.map((date) => format(date as Date, 'dd/MM/yyyy'))
.join(' - ');
}
return format(value, 'dd/MM/yyyy');
}, [value]);
/**
* Handles the selection of a date from the calendar.
*/
const handleSelect = useCallback(
(date: Value) => {
setOpen(false);
onChange?.(date);
},
[setOpen, onChange],
);
return (
<Popover.Root open={open}>
<Popover.Trigger>
<Input
{...props}
rightIcon={<CalendarIcon onClick={() => setOpen(true)} />}
readOnly
placeholder="Select a date..."
value={renderValue()}
className={input({ className })}
onClick={() => setOpen(true)}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content onInteractOutside={() => setOpen(false)}>
<Calendar
{...calendarProps}
selectRange={selectRange}
value={value}
onCancel={() => setOpen(false)}
onSelect={handleSelect}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -0,0 +1 @@
export * from './DatePicker';
@@ -50,6 +50,7 @@ export const inputTheme = tv(
'outline-offset-0',
'outline-border-danger',
'shadow-none',
'focus:outline-border-danger',
],
helperText: 'text-elements-danger',
},
@@ -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>
@@ -0,0 +1,84 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const switchTheme = tv({
slots: {
wrapper: ['flex', 'items-start', 'gap-4', 'w-[375px]'],
switch: [
'h-6',
'w-12',
'rounded-full',
'transition-all',
'duration-500',
'relative',
'cursor-default',
'shadow-inset',
'focus-ring',
'outline-none',
],
thumb: [
'block',
'h-4',
'w-4',
'translate-x-1',
'transition-transform',
'duration-100',
'will-change-transform',
'rounded-full',
'shadow-button',
'data-[state=checked]:translate-x-7',
'bg-controls-elevated',
],
label: [
'flex',
'flex-1',
'flex-col',
'px-1',
'gap-1',
'text-sm',
'text-elements-high-em',
'tracking-[-0.006em]',
],
description: ['text-xs', 'text-elements-low-em'],
},
variants: {
checked: {
true: {
switch: [
'bg-controls-primary',
'hover:bg-controls-primary-hovered',
'focus-visible:bg-controls-primary-hovered',
],
},
false: {
switch: [
'bg-controls-inset',
'hover:bg-controls-inset-hovered',
'focus-visible:bg-controls-inset-hovered',
],
},
},
disabled: {
true: {
switch: ['bg-controls-disabled', 'cursor-not-allowed'],
thumb: ['bg-elements-on-disabled'],
},
},
fullWidth: {
true: {
wrapper: ['w-full', 'justify-between'],
},
},
},
compoundVariants: [
{
checked: true,
disabled: true,
class: {
switch: ['bg-controls-disabled-active'],
thumb: ['bg-snowball-900'],
},
},
],
});
export type SwitchVariants = VariantProps<typeof switchTheme>;
@@ -0,0 +1,85 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { type SwitchProps as SwitchRadixProps } from '@radix-ui/react-switch';
import * as SwitchRadix from '@radix-ui/react-switch';
import { switchTheme, type SwitchVariants } from './Switch.theme';
interface SwitchProps
extends Omit<SwitchRadixProps, 'checked'>,
SwitchVariants {
/**
* The label of the switch.
*/
label?: string;
/**
* The description of the switch.
*/
description?: string;
/**
* Custom wrapper props for the switch.
*/
wrapperProps?: ComponentPropsWithoutRef<'div'>;
/**
* Function that is called when the checked state of the switch changes.
* @param checked The new checked state of the switch.
*/
onCheckedChange?(checked: boolean): void;
}
/**
* A switch is a component used for toggling between two states.
*/
export const Switch = ({
className,
checked,
label,
description,
disabled,
name,
wrapperProps,
fullWidth,
...props
}: SwitchProps) => {
const {
wrapper,
switch: switchClass,
thumb,
label: labelClass,
description: descriptionClass,
} = switchTheme({
checked,
disabled,
fullWidth,
});
const switchComponent = (
<SwitchRadix.Root
{...props}
checked={checked}
disabled={disabled}
className={switchClass({ className })}
>
<SwitchRadix.Thumb className={thumb()} />
</SwitchRadix.Root>
);
// If a label is provided, wrap the switch in a label element.
if (label) {
return (
<div
{...wrapperProps}
className={wrapper({ className: wrapperProps?.className })}
>
<label className={labelClass()} htmlFor={name}>
{label}
{description && (
<span className={descriptionClass()}>{description}</span>
)}
</label>
{switchComponent}
</div>
);
}
return switchComponent;
};
@@ -0,0 +1 @@
export * from './Switch';
@@ -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>;
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,2 @@
export * from './Toaster';
export * from './useToast';
@@ -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 };
+2 -2
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>,
@@ -18,6 +18,7 @@ import {
renderTabs,
renderVerticalTabs,
} from './renders/tabs';
import { Switch } from 'components/shared/Switch';
import { RADIO_OPTIONS } from './renders/radio';
import { Radio } from 'components/shared/Radio';
import {
@@ -25,11 +26,14 @@ import {
renderInlineNotifications,
} 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 = () => {
const [singleDate, setSingleDate] = useState<Value>();
const [dateRange, setDateRange] = useState<Value>();
const [switchValue, setSwitchValue] = useState(false);
const [selectedRadio, setSelectedRadio] = useState<string>('');
return (
@@ -45,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">
@@ -135,6 +149,16 @@ const Page = () => {
/>
</div>
</div>
<h1 className="text-2xl font-bold">Date Picker</h1>
<div className="flex flex-col gap-10 items-center justify-center">
<DatePicker value={singleDate} onChange={setSingleDate} />
<DatePicker
selectRange
value={dateRange}
onChange={setDateRange}
/>
</div>
</div>
<div className="w-full h border border-gray-200 px-20 my-10" />
@@ -163,6 +187,28 @@ const Page = () => {
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Switch */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Switch</h1>
<div className="flex flex-col gap-10 items-center justify-center">
<Switch
label="Label"
checked={switchValue}
onCheckedChange={setSwitchValue}
/>
<Switch
label="Label"
description="Additional information or context"
checked={switchValue}
onCheckedChange={setSwitchValue}
/>
<Switch disabled label="Disabled unchecked" />
<Switch disabled checked label="Disabled checked" />
</div>
</div>
<div className="w-full h border border-gray-200 px-20 my-10" />
{/* Radio */}
<div className="flex flex-col gap-10 items-center justify-between">
<h1 className="text-2xl font-bold">Radio</h1>
@@ -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>
);
};
+4
View File
@@ -154,12 +154,16 @@ export default withMT({
calendar:
'0px 3px 20px rgba(8, 47, 86, 0.1), 0px 0px 4px rgba(8, 47, 86, 0.14)',
field: '0px 1px 2px rgba(0, 0, 0, 0.04)',
inset: 'inset 0px 1px 0px rgba(8, 47, 86, 0.06)',
},
spacing: {
2.5: '0.625rem',
3.25: '0.8125rem',
3.5: '0.875rem',
},
zIndex: {
toast: '9999',
},
},
},
plugins: [],
+131 -1
View File
@@ -3389,6 +3389,23 @@
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-focus-guards@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-focus-scope@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-id@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
@@ -3397,6 +3414,28 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-popover@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@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-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.3"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-popper@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
@@ -3480,6 +3519,20 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-switch@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e"
integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/react-tabs@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -3495,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"
@@ -5421,7 +5491,7 @@ argparse@^2.0.1:
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.3:
aria-hidden@^1.1.1, aria-hidden@^1.1.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==
@@ -7407,6 +7477,11 @@ detect-newline@^3.0.0:
resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz"
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
detect-node@^2.0.4:
version "2.1.0"
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
@@ -8997,6 +9072,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@
has-symbols "^1.0.3"
hasown "^2.0.0"
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-own-enumerable-property-symbols@^3.0.0:
version "3.0.2"
resolved "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz"
@@ -9826,6 +9906,13 @@ interpret@^1.0.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
ip@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da"
@@ -13888,6 +13975,25 @@ react-refresh@^0.11.0:
resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz"
integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==
react-remove-scroll-bar@^2.3.3:
version "2.3.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz#cd2543b3ed7716c7c5b446342d21b0e0b303f47c"
integrity sha512-3cqjOqg6s0XbOjWvmasmqHch+RLxIEk2r/70rzGXuz3iIGQsQheEQyqYCBb5EECoD01Vo2SIbDqW4paLeLTASw==
dependencies:
react-style-singleton "^2.2.1"
tslib "^2.0.0"
react-remove-scroll@2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
dependencies:
react-remove-scroll-bar "^2.3.3"
react-style-singleton "^2.2.1"
tslib "^2.1.0"
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-router-dom@^6.20.1:
version "6.20.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.20.1.tgz#e34f8075b9304221420de3609e072bb349824984"
@@ -13958,6 +14064,15 @@ react-scripts@5.0.1:
optionalDependencies:
fsevents "^2.3.2"
react-style-singleton@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
dependencies:
get-nonce "^1.0.0"
invariant "^2.2.4"
tslib "^2.0.0"
react-syntax-highlighter@^15.5.0:
version "15.5.0"
resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20"
@@ -16119,6 +16234,21 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
use-callback-ref@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.1.tgz#9be64c3902cbd72b07fe55e56408ae3a26036fd0"
integrity sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==
dependencies:
tslib "^2.0.0"
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
usehooks-ts@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.10.0.tgz#ccd0d63168e4db95b061e26e585b0068d3fad0ed"