Skip to content

Commit

Permalink
Merge pull request #306 from MetroStar/add-toast-component-to-extras
Browse files Browse the repository at this point in the history
Add toast component to extras
  • Loading branch information
jbouder authored Dec 27, 2024
2 parents e19b316 + beb5762 commit bb830ba
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/comet-extras/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as DataTable } from './data-table';
export { default as Spinner } from './spinner';
export { default as Tabs, TabPanel } from './tabs';
export { default as Toggle } from './toggle';
export { default as Toast } from './toast';
1 change: 1 addition & 0 deletions packages/comet-extras/src/components/toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './toast';
72 changes: 72 additions & 0 deletions packages/comet-extras/src/components/toast/toast.stories.tsx
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 packages/comet-extras/src/components/toast/toast.style.css
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;
}
}
79 changes: 79 additions & 0 deletions packages/comet-extras/src/components/toast/toast.test.tsx
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();
});
});
});
93 changes: 93 additions & 0 deletions packages/comet-extras/src/components/toast/toast.tsx
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;

0 comments on commit bb830ba

Please sign in to comment.