Merged in SW-3317-move-toast-to-design-system (pull request #2716)
SW-3317 move toast to design system * chore: Move toast to design-system and add interaction tests * Move toast to design-system and add storybook tests * Merge branch 'master' of bitbucket.org:scandic-swap/web into SW-3317-move-toast-to-design-system * merge * move sonner dependency to @scandic-hotels/design-system Approved-by: Anton Gunnarsson
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { Toast } from './Toast'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { expect } from 'storybook/test'
|
||||
|
||||
import { config } from './variants.ts'
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: 'Components/Toasts/Toast',
|
||||
component: Toast,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
type: 'string',
|
||||
options: Object.keys(config.variants.variant),
|
||||
table: {
|
||||
defaultValue: { summary: 'info' },
|
||||
},
|
||||
},
|
||||
message: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
table: {
|
||||
defaultValue: { summary: 'Toast message' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Toast>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
message: 'This is a toast',
|
||||
},
|
||||
play: async ({ canvas }) => {
|
||||
const toast = await canvas.findByRole('status')
|
||||
expect(toast).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const DefaultWithCustomContent: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
children: (
|
||||
<p style={{ fontStyle: 'italic' }}>This is a custom info toast</p>
|
||||
),
|
||||
},
|
||||
play: async ({ canvas }) => {
|
||||
const toast = await canvas.findByRole('status')
|
||||
expect(toast).toBeVisible()
|
||||
expect(canvas.getByText('This is a custom info toast')).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
message: 'This is a success toast',
|
||||
},
|
||||
play: async ({ canvas, args }) => {
|
||||
const toast = await canvas.findByRole('status')
|
||||
expect(toast).toBeVisible()
|
||||
expect(canvas.getByText(args.message as string)).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: 'error',
|
||||
message: 'This is an error toast',
|
||||
},
|
||||
play: async ({ canvas, args }) => {
|
||||
const toast = await canvas.findByRole('alert')
|
||||
expect(toast).toBeVisible()
|
||||
expect(canvas.getByText(args.message as string)).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
message: 'This is a warning toast',
|
||||
},
|
||||
play: async ({ canvas, args }) => {
|
||||
const toast = await canvas.findByRole('alert')
|
||||
expect(toast).toBeVisible()
|
||||
expect(canvas.getByText(args.message as string)).toBeVisible()
|
||||
},
|
||||
}
|
||||
84
packages/design-system/lib/components/Toasts/Toast.tsx
Normal file
84
packages/design-system/lib/components/Toasts/Toast.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { toastVariants } from './variants'
|
||||
import { MaterialIcon, MaterialIconSetIconProps } from '../Icons/MaterialIcon'
|
||||
|
||||
import styles from './toasts.module.css'
|
||||
import { Typography } from '../Typography'
|
||||
import { useIntl } from 'react-intl'
|
||||
import { IconButton } from '../IconButton'
|
||||
|
||||
export type ToastsProps = VariantProps<typeof toastVariants> & {
|
||||
variant: NonNullable<VariantProps<typeof toastVariants>['variant']>
|
||||
onClose?: () => void
|
||||
} & (
|
||||
| {
|
||||
children: React.ReactNode
|
||||
message?: never
|
||||
}
|
||||
| {
|
||||
children?: never
|
||||
message: React.ReactNode
|
||||
}
|
||||
)
|
||||
|
||||
export function Toast({ children, message, onClose, variant }: ToastsProps) {
|
||||
const className = toastVariants({ variant })
|
||||
const intl = useIntl()
|
||||
const Icon = <AlertIcon variant={variant} color="Icon/Inverted" />
|
||||
|
||||
return (
|
||||
<div className={className} role={getRole(variant)} aria-atomic="true">
|
||||
<div className={styles.iconContainer}>{Icon && Icon}</div>
|
||||
{message ? (
|
||||
<Typography variant={'Body/Paragraph/mdRegular'}>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</Typography>
|
||||
) : (
|
||||
<div className={styles.content}>{children}</div>
|
||||
)}
|
||||
{onClose ? (
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: 'Dismiss notification',
|
||||
})}
|
||||
theme={'Black'}
|
||||
>
|
||||
<MaterialIcon icon="close" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AlertIconProps {
|
||||
variant: ToastsProps['variant']
|
||||
}
|
||||
function AlertIcon({
|
||||
variant,
|
||||
...props
|
||||
}: AlertIconProps & MaterialIconSetIconProps) {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return <MaterialIcon icon="cancel" {...props} />
|
||||
case 'info':
|
||||
return <MaterialIcon icon="info" {...props} />
|
||||
case 'success':
|
||||
return <MaterialIcon icon="check_circle" {...props} />
|
||||
case 'warning':
|
||||
return <MaterialIcon icon="warning" {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function getRole(variant: ToastsProps['variant']) {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
case 'warning':
|
||||
return 'alert'
|
||||
case 'info':
|
||||
case 'success':
|
||||
default:
|
||||
return 'status'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { toast } from './index.tsx'
|
||||
import { Toast } from './Toast.tsx'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
import { config } from './variants.ts'
|
||||
import { ToastHandler } from './ToastHandler.tsx'
|
||||
import { Button } from '../Button/Button.tsx'
|
||||
import { expect, waitFor } from 'storybook/test'
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: 'Components/Toasts/ToastHandler',
|
||||
component: Toast,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
type: 'string',
|
||||
options: Object.keys(config.variants.variant),
|
||||
table: {
|
||||
defaultValue: { summary: 'info' },
|
||||
},
|
||||
},
|
||||
message: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
table: {
|
||||
defaultValue: { summary: 'Toast message' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Toast>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
variant: 'info',
|
||||
message: 'This is a toast',
|
||||
},
|
||||
render: (args) => {
|
||||
return <Renderer variant={args.variant} message={args.message as string} />
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent, args }) => {
|
||||
let toast = canvas.queryByRole('status')
|
||||
expect(toast).not.toBeInTheDocument()
|
||||
|
||||
const showToastButton = await canvas.findByRole('button')
|
||||
await userEvent.click(showToastButton)
|
||||
|
||||
toast = await canvas.findByRole(
|
||||
['info', 'success'].indexOf(args.variant) !== -1 ? 'status' : 'alert'
|
||||
)
|
||||
await waitFor(async () => await expect(toast).toBeVisible())
|
||||
|
||||
const closeButton = await canvas.findByLabelText('Dismiss notification')
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
await waitFor(async () => await expect(toast).not.toBeVisible())
|
||||
},
|
||||
}
|
||||
|
||||
const Renderer = ({
|
||||
message,
|
||||
variant,
|
||||
}: {
|
||||
message: string
|
||||
variant: 'info' | 'success' | 'warning' | 'error'
|
||||
onDismiss?: () => void
|
||||
}) => {
|
||||
const text = 'Show Toast'
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="Primary"
|
||||
onPress={() => {
|
||||
toast[variant](message)
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
<ToastHandler />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export function ToastHandler() {
|
||||
return <Toaster position="bottom-right" duration={5000} />
|
||||
}
|
||||
49
packages/design-system/lib/components/Toasts/index.tsx
Normal file
49
packages/design-system/lib/components/Toasts/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type ExternalToast, toast as sonnerToast } from 'sonner'
|
||||
import { Toast } from './Toast'
|
||||
|
||||
export const toast = {
|
||||
success: (message: React.ReactNode, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="success"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
info: (message: React.ReactNode, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="info"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
error: (message: React.ReactNode, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="error"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
warning: (message: React.ReactNode, options?: ExternalToast) =>
|
||||
sonnerToast.custom(
|
||||
(t) => (
|
||||
<Toast
|
||||
variant="warning"
|
||||
message={message}
|
||||
onClose={() => sonnerToast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
options
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.toast {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.08);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.toast {
|
||||
width: var(--width);
|
||||
}
|
||||
}
|
||||
|
||||
.toast .message {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.success {
|
||||
--icon-background-color: var(--UI-Semantic-Success);
|
||||
}
|
||||
|
||||
.error {
|
||||
--icon-background-color: var(--UI-Semantic-Error);
|
||||
}
|
||||
|
||||
.warning {
|
||||
--icon-background-color: var(--UI-Semantic-Warning);
|
||||
}
|
||||
|
||||
.info {
|
||||
--icon-background-color: var(--UI-Semantic-Information);
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--icon-background-color);
|
||||
padding: var(--Spacing-x2);
|
||||
height: 100%;
|
||||
}
|
||||
16
packages/design-system/lib/components/Toasts/variants.ts
Normal file
16
packages/design-system/lib/components/Toasts/variants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import styles from './toasts.module.css'
|
||||
|
||||
export const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
success: styles.success,
|
||||
info: styles.info,
|
||||
warning: styles.warning,
|
||||
error: styles.error,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const toastVariants = cva(styles.toast, config)
|
||||
@@ -154,6 +154,8 @@
|
||||
"./Table": "./lib/components/Table/index.tsx",
|
||||
"./Title": "./lib/components/Title/index.tsx",
|
||||
"./Tooltip": "./lib/components/Tooltip/index.tsx",
|
||||
"./Toast": "./lib/components/Toasts/index.tsx",
|
||||
"./ToastHandler": "./lib/components/Toasts/ToastHandler.tsx",
|
||||
"./TripAdvisorChip": "./lib/components/TripAdvisorChip/index.tsx",
|
||||
"./Typography": "./lib/components/Typography/index.tsx",
|
||||
"./JsonToHtml": "./lib/components/JsonToHtml/JsonToHtml.tsx",
|
||||
@@ -196,7 +198,8 @@
|
||||
"test:browser": "vitest --config=vitest.browser.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scandic-hotels/common": "workspace:*"
|
||||
"@scandic-hotels/common": "workspace:*",
|
||||
"sonner": "^2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.27.4",
|
||||
|
||||
Reference in New Issue
Block a user