Skip to content

Commit

Permalink
feat(ui): #1807: add Toast v2 UI component (#1817)
Browse files Browse the repository at this point in the history
* feat(ui): #1807: add Toast v2 UI component

* feat(ui): use sonner for UI v2 toasts

* chore: changeset
  • Loading branch information
VanishMax authored Oct 28, 2024
1 parent 859e287 commit e3778eb
Show file tree
Hide file tree
Showing 7 changed files with 887 additions and 155 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-comics-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Add `ToastProvider` and `openToast` function to the v2 UI components
4 changes: 2 additions & 2 deletions packages/ui/src/Icon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LucideIcon } from 'lucide-react';
import { ComponentProps } from 'react';
import { ComponentProps, FC } from 'react';
import { DefaultTheme, useTheme } from 'styled-components';

export type IconSize = 'sm' | 'md' | 'lg';
Expand All @@ -13,7 +13,7 @@ export interface IconProps {
* <Icon IconComponent={ChevronRight} />
* ```
*/
IconComponent: LucideIcon;
IconComponent: LucideIcon | FC;
/**
* - `sm`: 16px square
* - `md`: 24px square
Expand Down
100 changes: 100 additions & 0 deletions packages/ui/src/Toast/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/react';

import { openToast, ToastProvider, ToastType } from '.';
import { Button } from '../Button';
import { Tooltip } from '../Tooltip';
import { Text } from '../Text';
import { styled } from 'styled-components';

const meta: Meta<typeof ToastProvider> = {
component: ToastProvider,
tags: ['autodocs', '!dev'],
argTypes: {},
};
export default meta;

type Story = StoryObj<typeof ToastProvider>;

const Row = styled.div`
display: flex;
flex-direction: row;
gap: ${props => props.theme.spacing(2)};
`;

export const Basic: Story = {
render: function Render() {
const toast = (type: ToastType) => {
openToast({
type,
message: 'Hello, world!',
description: 'Additional text can possibly be long enough lorem ipsum dolor sit amet.',
});
};

const upload = () => {
const t = openToast({
type: 'loading',
message: 'Hello, world!',
});

setTimeout(() => {
t.update({
type: 'error',
message: 'Failed!',
description: 'Unknown error',
});
}, 2000);
};

const action = () => {
openToast({
type: 'warning',
message: 'Do you confirm?',
dismissible: false,
persistent: true,
action: {
label: 'Yes!',
onClick: () => {
openToast({
type: 'success',
message: 'Confirmed!',
dismissible: false,
});
},
},
});
};

return (
<>
<ToastProvider />

<Text h4>All style types of toasts</Text>

<Row>
<Button onClick={() => toast('info')}>Info</Button>
<Button onClick={() => toast('success')}>Success</Button>
<Button onClick={() => toast('warning')}>Warning</Button>
<Button onClick={() => toast('error')}>Error</Button>
<Tooltip message='Cannot be closed by user until status is updated'>
<Button onClick={() => toast('loading')}>Loading</Button>
</Tooltip>
</Row>

<Text h4>Updating toast</Text>

<Row>
<Tooltip message='Starts as a loading toast, after 2 seconds updated to the error type'>
<Button onClick={upload}>Open</Button>
</Tooltip>
</Row>

<Text h4>Action toast</Text>

<Row>
<Button onClick={action}>Open</Button>
</Row>
</>
);
},
};
3 changes: 3 additions & 0 deletions packages/ui/src/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ToastProvider } from './provider';
export { openToast } from './open';
export type { Toast, ToastProps, ToastType } from './open';
107 changes: 107 additions & 0 deletions packages/ui/src/Toast/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { toast, ExternalToast } from 'sonner';
import { ReactNode } from 'react';

export type ToastType = 'success' | 'info' | 'warning' | 'error' | 'loading';
type ToastFn = (message: ReactNode, options?: ExternalToast) => string | number;
type ToastId = string | number;

const toastFnMap: Record<ToastType, ToastFn> = {
success: toast.success,
info: toast.info,
warning: toast.warning,
error: toast.error,
loading: toast.loading,
};

export interface ToastProps {
type: ToastType;
message: string;
description?: string;
persistent?: boolean;
dismissible?: boolean;
action?: ExternalToast['action'];
}

export interface Toast {
update: (newProps: Partial<ToastProps>) => void;
dismiss: VoidFunction;
}

/**
* If `<ToastProvider />` exists in the document, opens a toast with provided type and options.
* By default, the toast is dismissible and has a duration of 4000 milliseconds. It can
* be programmatically updated to another type and content without re-opening the toast.
*
* Example:
*
* ```tsx
* import { ToastProvider, openToast } from '@penumbra-zone/ui/Toast';
* import { ToastProvider, openToast } from '@penumbra-zone/ui/Button';
*
* const Component = () => {
* const open = () => {
* const toast = openToast({
* type: 'loading',
* message: 'Loading...',
* });
*
* setTimeout(() => {
* toast.update({
* type: 'error',
* message: 'Failed!',
* description: 'Unknown error'
* });
* }, 2000);
* };
*
* return (
* <>
* <ToastProvider />
* <Button onClick={open}>Open</Button>
* </>
* );
* };
* ```
*/
export const openToast = (props: ToastProps): Toast => {
let options = props;
let id: ToastId | undefined = undefined;

const open = () => {
const fn = toastFnMap[options.type];

id = fn(options.message, {
id,
description: options.description,
closeButton: options.dismissible ?? true,
dismissible: options.dismissible ?? true,
duration: options.persistent ? Infinity : 4000,
action: options.action,
});
};

const dismiss: Toast['dismiss'] = () => {
if (typeof id === 'undefined') {
return;
}

toast.dismiss(id);
id = undefined;
};

const update: Toast['update'] = newProps => {
if (typeof id === 'undefined') {
return;
}

options = { ...options, ...newProps };
open();
};

open();

return {
dismiss,
update,
};
};
41 changes: 41 additions & 0 deletions packages/ui/src/Toast/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Toaster } from 'sonner';

/**
* If `<ToastProvider />` exists in the document, you can call `openToast` function to open a toast with provided type and options.
* By default, the toast is dismissible and has a duration of 4000 milliseconds. It can
* be programmatically updated to another type and content without re-opening the toast.
*
* Example:
*
* ```tsx
* import { ToastProvider, openToast } from '@penumbra-zone/ui/Toast';
* import { ToastProvider, openToast } from '@penumbra-zone/ui/Button';
*
* const Component = () => {
* const open = () => {
* const toast = openToast({
* type: 'loading',
* message: 'Loading...',
* });
*
* setTimeout(() => {
* toast.update({
* type: 'error',
* message: 'Failed!',
* description: 'Unknown error'
* });
* }, 2000);
* };
*
* return (
* <>
* <ToastProvider />
* <Button onClick={open}>Open</Button>
* </>
* );
* };
* ```
*/
export const ToastProvider: typeof Toaster = ({ ...props }) => {
return <Toaster theme='dark' richColors expand {...props} />;
};
Loading

0 comments on commit e3778eb

Please sign in to comment.