-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #306 from MetroStar/add-toast-component-to-extras
Add toast component to extras
- Loading branch information
Showing
6 changed files
with
336 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from './toast'; |
72 changes: 72 additions & 0 deletions
72
packages/comet-extras/src/components/toast/toast.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import React, { useState } from 'react'; | ||
import { Meta, StoryFn } from '@storybook/react'; | ||
import Toast, { ToastProps } from './toast'; | ||
import Button from '../../../../comet-uswds/src/components/button/button'; | ||
|
||
const meta: Meta<typeof Toast> = { | ||
title: 'Extras/Toast', | ||
component: Toast, | ||
argTypes: { | ||
id: { control: 'text' }, | ||
message: { control: 'text' }, | ||
duration: { control: 'number' }, | ||
type: { control: 'select', required: true }, | ||
onClose: { action: 'close' }, | ||
allowClose: { control: 'boolean' }, | ||
className: { control: false }, | ||
}, | ||
parameters: { | ||
docs: { | ||
source: { | ||
type: 'code', | ||
}, | ||
}, | ||
}, | ||
}; | ||
export default meta; | ||
|
||
const Template: StoryFn<typeof Toast> = (args: ToastProps) => { | ||
const [toasts, setToasts] = useState<any[]>([]); | ||
|
||
const addToast = () => { | ||
const newToast = { | ||
key: args.id, | ||
id: `toast-${args.type}`, | ||
message: `${!args.message ? 'Default toast notification for ' + args.type : args.message}`, | ||
type: `${args.type}`, | ||
duration: `${!args.duration ? 3000 : args.duration}`, | ||
allowClose: args.allowClose, | ||
}; | ||
setToasts((prev) => [...prev, newToast]); | ||
}; | ||
|
||
return ( | ||
<div style={{ padding: '16px' }}> | ||
<Button id="toast-button" onClick={() => addToast()}> | ||
Send Toast | ||
</Button> | ||
|
||
<div> | ||
{toasts.map((toast) => ( | ||
<Toast | ||
key={toast.id} | ||
id={toast.id} | ||
message={toast.message} | ||
type={toast.type} | ||
duration={toast.duration} | ||
allowClose={toast.allowClose} | ||
/> | ||
))} | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
export const Default = Template.bind({}); | ||
Default.args = { | ||
id: 'toast-info', | ||
message: 'This is a toast notification', | ||
type: 'info', | ||
duration: 3000, | ||
allowClose: true, | ||
}; |
90 changes: 90 additions & 0 deletions
90
packages/comet-extras/src/components/toast/toast.style.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
.toast { | ||
border-left: 0.5rem solid #adadad; | ||
display: flex; | ||
align-items: center; | ||
justify-content: space-between; | ||
min-width: 300px; | ||
max-width: 400px; | ||
padding: 12px 16px; | ||
color: #1b1b1b; | ||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | ||
animation: slideIn 0.3s ease-out forwards; | ||
position: fixed; | ||
top: 20px; | ||
right: 20px; | ||
} | ||
|
||
.toast--info { | ||
background-color: #e7f6f8; | ||
border-left-color: #00bde3; | ||
} | ||
.toast--warning { | ||
background-color: #faf3d1; | ||
border-left-color: #ffbe2e; | ||
} | ||
.toast--success { | ||
background-color: #ecf3ec; | ||
border-left-color: #00a91c; | ||
} | ||
.toast--error { | ||
background-color: #f4e3db; | ||
border-left-color: #d54309; | ||
} | ||
.toast--emergency { | ||
background-color: #9c3d10; | ||
border-left-color: #9c3d10; | ||
} | ||
|
||
.toast--emergency *, | ||
.toast--emergency .toast__close-button { | ||
color: white; | ||
} | ||
|
||
.toast__message { | ||
margin: 0; | ||
flex-grow: 1; | ||
} | ||
|
||
.toast__close-button { | ||
background: none; | ||
border: none; | ||
color: #1b1b1b; | ||
cursor: pointer; | ||
padding: 4px; | ||
margin-left: 12px; | ||
border-radius: 50%; | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
transition: background-color 0.2s; | ||
} | ||
|
||
.toast--isLeaving { | ||
animation: slideOut 0.3s ease-in forwards; | ||
} | ||
|
||
@keyframes slideIn { | ||
from { | ||
transform: translateX(100%); | ||
opacity: 0; | ||
} | ||
to { | ||
transform: translateX(0); | ||
opacity: 1; | ||
} | ||
} | ||
|
||
.toast--isLeaving { | ||
animation: slideOut 0.3s ease-in forwards; | ||
} | ||
|
||
@keyframes slideOut { | ||
from { | ||
transform: translateX(0); | ||
opacity: 1; | ||
} | ||
to { | ||
transform: translateX(100%); | ||
opacity: 0; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; | ||
import { describe, test, expect, vi } from 'vitest'; | ||
import { axe } from 'jest-axe'; | ||
import { Toast } from './toast'; // Assuming the Toast component is in the same directory | ||
|
||
describe('Toast Component Tests', () => { | ||
test('should render with no accessibility violations', async () => { | ||
const { container } = render( | ||
<Toast id="test-accessibility-toast" message="Testing toast notification" />, | ||
); | ||
expect(await axe(container)).toHaveNoViolations(); | ||
}); | ||
|
||
test('should render an info Toast notification', () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="info" />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
expect(container.querySelector('#test-toast')).toHaveClass('toast--info'); | ||
}); | ||
|
||
test('should render a warning Toast notification', () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="warning" />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
expect(container.querySelector('#test-toast')).toHaveClass('toast--warning'); | ||
}); | ||
|
||
test('should render a success Toast notification', () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="success" />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
expect(container.querySelector('#test-toast')).toHaveClass('toast--success'); | ||
}); | ||
|
||
test('should render an error Toast notification', () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="error" />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
expect(container.querySelector('#test-toast')).toHaveClass('toast--error'); | ||
}); | ||
|
||
test('should render an emergency Toast notification', () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="emergency" />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
expect(container.querySelector('#test-toast')).toHaveClass('toast--emergency'); | ||
}); | ||
|
||
test('clicking close button triggers onClose', async () => { | ||
vi.useFakeTimers(); | ||
const handleClose = vi.fn(); | ||
|
||
render(<Toast id="close-toast" message="Test message" onClose={handleClose} />); | ||
|
||
const closeButton = screen.getByRole('button'); | ||
await fireEvent.click(closeButton); | ||
|
||
// Wait for the animation timeout (300ms in the component) | ||
vi.advanceTimersByTime(300); | ||
|
||
expect(handleClose).toHaveBeenCalled(); | ||
vi.useRealTimers(); | ||
}); | ||
|
||
test('should wait for toast to disappear', async () => { | ||
const { container } = render( | ||
<Toast id="test-toast" message="Testing message for notification" type="info" duration={1} />, | ||
); | ||
expect(container.querySelector('#test-toast')).toBeTruthy(); | ||
await waitFor(async () => { | ||
expect(container.querySelector('#test-toast')).not.toBeTruthy(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import React, { useState, useEffect } from 'react'; | ||
import classnames from 'classnames'; | ||
import './toast.style.css'; | ||
|
||
// Props interface for the Toast component | ||
export interface ToastProps { | ||
/** | ||
* The unique identifier for this component | ||
*/ | ||
id: string; | ||
/** | ||
* The message to display in the toast | ||
* */ | ||
message?: string; | ||
/** | ||
* Duration in milliseconds to show the toast. Set to 0 for no auto-dismiss | ||
* */ | ||
duration?: number; | ||
/** | ||
* The type of toast which determines its color scheme | ||
* */ | ||
type?: 'success' | 'error' | 'warning' | 'info' | 'emergency'; | ||
/** | ||
* Callback function when toast is closed either manually or automatically | ||
* */ | ||
onClose?: () => void; | ||
/** | ||
* A custom class to apply to the component | ||
*/ | ||
className?: string; | ||
/** | ||
* Whether or not to display the close button | ||
*/ | ||
allowClose?: boolean; | ||
} | ||
|
||
export const Toast = ({ | ||
id, | ||
message = 'This is a toast notification', | ||
duration = 3000, | ||
type = 'info', | ||
className = '', | ||
onClose = () => {}, | ||
allowClose = true, | ||
}: ToastProps): React.ReactElement => { | ||
const [isVisible, setIsVisible] = useState(true); | ||
const [isLeaving, setIsLeaving] = useState(false); | ||
|
||
const classes = classnames( | ||
'toast', | ||
`toast--${type}`, | ||
className, | ||
`${isLeaving ? 'toast--isLeaving' : ''}`, | ||
); | ||
|
||
const dismissToast = () => { | ||
setIsLeaving(true); | ||
setTimeout(() => { | ||
setIsVisible(false); | ||
onClose(); | ||
}, 300); | ||
}; | ||
|
||
useEffect(() => { | ||
if (duration > 0) { | ||
const timer = setTimeout(() => { | ||
dismissToast(); | ||
}, duration); | ||
|
||
return () => clearTimeout(timer); | ||
} | ||
}, [duration]); | ||
|
||
if (!isVisible) return <></>; | ||
|
||
return ( | ||
<div id={id} className={classes}> | ||
<p className="toast__message">{message}</p> | ||
{allowClose && ( | ||
<button | ||
onClick={dismissToast} | ||
className="toast__close-button" | ||
aria-label="Close notification" | ||
role="button" | ||
> | ||
✕ | ||
</button> | ||
)} | ||
</div> | ||
); | ||
}; | ||
|
||
export default Toast; |