Skip to content

Commit

Permalink
feat: improve toaster
Browse files Browse the repository at this point in the history
  • Loading branch information
Pagebakers committed Jan 11, 2025
1 parent 8ac704a commit ac2d80d
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 123 deletions.
5 changes: 5 additions & 0 deletions .changeset/kind-points-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@saas-ui/react': minor
---

Improved toaster styles and allow setting global defaults
4 changes: 3 additions & 1 deletion packages/saas-ui-react/src/components/toaster/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './toaster.tsx'
export { Toaster, toast } from './toaster.tsx'

export type { ToasterProps } from './toaster.tsx'
93 changes: 93 additions & 0 deletions packages/saas-ui-react/src/components/toaster/toast.recipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { defineSlotRecipe } from '@chakra-ui/react'
import { toastAnatomy } from '@chakra-ui/react/anatomy'

export const toastSlotRecipe = defineSlotRecipe({
slots: toastAnatomy.keys(),
className: 'chakra-toast',
base: {
root: {
width: 'full',
display: 'flex',
alignItems: 'flex-start',
position: 'relative',
gap: '2',
py: '3',
ps: '3',
pe: '6',
borderRadius: 'md',
borderWidth: '1px',
translate: 'var(--x) var(--y)',
scale: 'var(--scale)',
zIndex: 'var(--z-index)',
height: 'var(--height, var(--toast-height))',
opacity: 'var(--opacity)',
willChange: 'translate, opacity, scale, height',
transition:
'translate 400ms, scale 400ms, opacity 400ms, height 200ms, box-shadow 200ms',
transitionTimingFunction: 'bounce-in',
_closed: {
transition: 'translate 400ms, scale 400ms, height 200ms, opacity 200ms',
transitionTimingFunction: 'bounce-out',
},

bg: 'bg.panel',
color: 'fg',
boxShadow: 'lg',
overflow: 'hidden',
'--toast-indicated-color': 'colors.fg.muted',
'&[data-type=warning]': {
'--toast-indicated-color': 'colors.fg.warning',
},
'&[data-type=success]': {
'--toast-indicated-color': 'colors.fg.success',
},
'&[data-type=error]': {
'--toast-indicated-color': 'colors.fg.error',
},
'&[data-overlap]': {
_before: {
content: '""',
position: 'absolute',
inset: '0',
maskImage: 'linear-gradient(to bottom, transparent, black 50%)',
},
},
},
title: {
fontWeight: 'medium',
textStyle: 'sm',
marginEnd: '2',
},
description: {
display: 'inline',
textStyle: 'sm',
opacity: '0.8',
},
indicator: {
flexShrink: '0',
boxSize: '5',
color: 'var(--toast-indicated-color)',
},
actionTrigger: {
cursor: 'button',
textStyle: 'sm',
fontWeight: 'medium',
height: '6',
px: '3',
ms: '-3',
borderRadius: 'sm',
alignSelf: 'flex-start',
transition: 'background 200ms',
color: 'colorPalette.solid/80',
_hover: {
bg: 'bg.subtle',
color: 'colorPalette.solid',
},
},
closeTrigger: {
position: 'absolute',
top: '2',
insetEnd: '2',
},
},
})
89 changes: 89 additions & 0 deletions packages/saas-ui-react/src/components/toaster/toaster.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react'

import { Button, HStack } from '@chakra-ui/react'

import { Toaster, toast } from './index.ts'

export default {
title: 'Components/Toaster',
decorators: [
(Story) => (
<>
<Toaster overlap />
<Story />
</>
),
],
}

export const Default = () => {
return (
<HStack>
<Button
onClick={() => toast.create({ title: 'Changes saved successfully' })}
>
Default toast
</Button>
<Button
onClick={() => toast.success({ title: 'Changes saved successfully' })}
>
Success toast
</Button>
<Button
onClick={() =>
toast.error({
title: 'Something went wrong',
description:
'Please try again or contact support if the issue persists.',
})
}
>
Error toast
</Button>
<Button
onClick={() =>
toast.create({
type: 'warning',
title: 'Connection disrupted',
description: 'Please check your internet connection.',
})
}
>
Warning toast
</Button>
<Button
onClick={() =>
toast.success({
title: 'Task created',
description: 'TSK-123456',
action: { label: 'View task', onClick: () => alert('Clicked') },
})
}
>
Toast with action
</Button>
<Button
onClick={() =>
toast.promise(
new Promise((resolve) => {
setTimeout(() => resolve('Task created'), 1000)
}),
{
loading: {
title: 'Creating your account',
},
success: {
title: 'Account created',
},
error: {
title: 'Error',
},
},
)
}
>
Promise
</Button>
</HStack>
)
}
106 changes: 81 additions & 25 deletions packages/saas-ui-react/src/components/toaster/toaster.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,104 @@
'use client'

import React, { useCallback, useMemo, useState } from 'react'

import {
Toaster as ChakraToaster,
CreateToasterReturn,
type CreateToasterProps,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from '@chakra-ui/react'

export const toast = createToaster({
import { CloseButton } from '#components/close-button/close-button.js'

const defaultOptions: CreateToasterProps = {
placement: 'bottom-end',
pauseOnPageIdle: true,
})
}

export interface ToasterProps extends CreateToasterReturn {
children: React.ReactNode
export let toast = createToaster(defaultOptions)

export interface ToasterProps extends Partial<CreateToasterProps> {
closable?: boolean
}

export const Toaster = () => {
export const Toaster = (props?: ToasterProps) => {
const { closable: defaultClosable = true, ...options } = props || {}

const toaster = useMemo(() => {
toast = createToaster({
...defaultOptions,
...options,
})

return toast
}, [options])

return (
<Portal>
<ChakraToaster toaster={toast} insetInline={{ mdDown: '4' }}>
{(toast) => (
<Toast.Root width={{ md: 'sm' }}>
{toast.type === 'loading' ? (
<Spinner size="sm" color="colorPalette.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
{(toast) => {
const closable =
toast.meta?.closable === false
? false
: defaultClosable && toast.type !== 'loading'

return (
<ToastRoot>
{toast.type === 'loading' ? (
<Spinner size="sm" color="colorPalette.solid" mt="0.5" />
) : (
<Toast.Indicator />
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.meta?.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}

{toast.action && (
<Toast.ActionTrigger>
{toast.action.label}
</Toast.ActionTrigger>
)}
</Stack>

{closable !== false && (
<Toast.CloseTrigger>
<CloseButton size="xs" />
</Toast.CloseTrigger>
)}
</ToastRoot>
)
}}
</ChakraToaster>
</Portal>
)
}

/**
* Since the height of the toast is dynamic, we need to set the height
* in the CSS variable `--toast-height` so css transitions can be applied.
*/
function ToastRoot(props: { children: React.ReactNode }) {
const [rect, setRect] = useState<DOMRect>()

const rectCallbackRef = useCallback((el: HTMLDivElement) => {
setRect(el?.getBoundingClientRect())
}, [])

return (
<Toast.Root
ref={rectCallbackRef}
width={{ md: 'sm' }}
css={{
'--toast-height': `${rect?.height}px`,
}}
>
{props.children}
</Toast.Root>
)
}
Loading

0 comments on commit ac2d80d

Please sign in to comment.