Skip to content

Commit

Permalink
feat: new toast component
Browse files Browse the repository at this point in the history
  • Loading branch information
Kyrylo Hudym-Levkovych authored and Kyrylo Hudym-Levkovych committed Dec 21, 2023
1 parent 0fa83e8 commit 1517acf
Show file tree
Hide file tree
Showing 16 changed files with 423 additions and 515 deletions.
22 changes: 22 additions & 0 deletions src/Toast/EventEmitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class EventEmitter {
constructor() {
this.events = {};
}

subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}

emit(event, data) {
const eventSubscribers = this.events[event];
if (eventSubscribers) {
eventSubscribers.forEach(callback => callback(data));
}
}
}

// eslint-disable-next-line import/prefer-default-export
export const toastEmitter = new EventEmitter();
138 changes: 71 additions & 67 deletions src/Toast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
title: 'Toast'
type: 'component'
components:
- Toast
- ToastContainer
- toast
categories:
- Overlays
status: 'New'
Expand All @@ -11,85 +12,88 @@ devStatus: 'Done'
notes: ''
---

``Toast`` is a pop-up style message that shows the user a brief, fleeting, dismissible message about a successful app process.
`Toast` is a pop-up style message that shows the user a brief, fleeting, dismissible message about a successful app process.

``Toasts`` sit fixed to the bottom left of the window.
## Features

- **Customizable Appearance**: Choose the window position for toast.
- **Interactive**: Includes a close button for manual dismissal.
- **Auto-dismiss**: Disappears automatically after a set duration.
- **Hover Interactivity**: Auto-dismiss timer pauses on hover or focus, allowing users to interact with the content.

## Behaviors

<ul>
<li>Auto-dismiss: Toast automatically dismisses after 5 seconds by default.</li>
<li>Disable timer: On hover of the Toast container. On hover or focus of dismiss icon or optional button</li>
<li>Re-enable timer: On mouse leave of the Toast container. On blur of dismiss icon or option button</li>
<li>Auto-dismiss timer: 5 - 15 second range.</li>
</ul>
- Auto-dismiss: Toast automatically dismisses after a default duration of 5 seconds.
- Disable timer: Pause the auto-dismiss timer on hover or focus of the Toast or the dismiss icon.
- Re-enable timer: Resume the auto-dismiss timer on mouse leave or blur of the Toast component.

## Basic Usage

```jsx live
() => {
const [show, setShow] = useState(false);

return (
<>
<Toast
onClose={() => setShow(false)}
show={show}
>
Example of a basic Toast.
</Toast>

<Button variant="primary" onClick={() => setShow(true)}>Show Toast</Button>
</>
);
}
```

## With Button

```jsx live
() => {
const [show, setShow] = useState(false);
const [position, setPosition] = useState('bottom-left');
const [timer, setTimer] = useState(5000);
const [message, setMessage] = useState('Example of a basic Toast.');
const [actions, setActions] = useState([]);

return (
<>
<Toast
action={{
label: "Optional Button",
onClick: () => console.log('You clicked the action button.')
}}
onClose={() => setShow(false)}
show={show}
>
Success! Example of a Toast with a button.
</Toast>

<Button variant="primary" onClick={() => setShow(true)}>Show Toast</Button>
</>
);
}
```

## With Link

```jsx live
() => {
const [show, setShow] = useState(false);
const testAction = {
label: "Optional Button",
onClick: () => console.log('You clicked the action button.')
};

return (
<>
<Toast
action={{
label: "Optional Link",
href: "#"
}}
onClose={() => setShow(false)}
show={show}
>
Success! Example of a Toast with a link.
</Toast>

<Button variant="primary" onClick={() => setShow(true)}>Show Toast</Button>
<div className="mt-3">
Message:
<Form.Control
className="mt-1"
as="textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>

<div className="mt-3">
Duration (ms):
<Form.Control className="mt-1" type="number" value={timer} onChange={(e) => setTimer(Number(e.target.value))} />
</div>

<div className="mt-3 mb-4">
Position:
<Form.Control
as="select"
className="mt-1"
value={position}
onChange={(e) => setPosition(e.target.value)}
>
<option value="top-left">Top Left</option>
<option value="top-right">Top Right</option>
<option value="bottom-left">Bottom Left</option>
<option value="bottom-right">Bottom Right</option>
</Form.Control>
</div>

<div className="mt-3 mb-4">
Add and remove actions:

<p>Total added: {actions.length}</p>

<Stack className="mt-2" direction="horizontal" gap="2">
<Button onClick={() => setActions(prevState => [...prevState, testAction])} variant="tertiary">
Add action
</Button>
<Button onClick={() => setActions([])} variant="tertiary">
Clear actions
</Button>
</Stack>
</div>


<Button onClick={() => toast({ message, duration: timer, actions})}>
Show Toast
</Button>

<ToastContainer position={position} />
</>
);
}
Expand Down
92 changes: 0 additions & 92 deletions src/Toast/Toast.test.jsx

This file was deleted.

100 changes: 71 additions & 29 deletions src/Toast/ToastContainer.jsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,82 @@
import React from 'react';
/* eslint-disable react/prop-types */
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

class ToastContainer extends React.Component {
constructor(props) {
super(props);
this.toastRootName = 'toast-root';
if (typeof document === 'undefined') {
this.rootElement = null;
} else if (document.getElementById(this.toastRootName)) {
this.rootElement = document.getElementById(this.toastRootName);
} else {
const rootElement = document.createElement('div');
rootElement.setAttribute('id', this.toastRootName);
rootElement.setAttribute('class', 'toast-container');
rootElement.setAttribute('role', 'alert');
rootElement.setAttribute('aria-live', 'polite');
rootElement.setAttribute('aria-atomic', 'true');
this.rootElement = document.body.appendChild(rootElement);
}
import { toastEmitter } from './EventEmitter';
import Toast from '.';

const positionStyles = {
'top-left': {
top: '0', left: '0', right: 'auto', bottom: 'auto',
},
'top-right': {
top: '0', right: '0', left: 'auto', bottom: 'auto',
},
'bottom-left': {
bottom: '0', left: '0', right: 'auto', top: 'auto',
},
'bottom-right': {
bottom: '0', right: '0', left: 'auto', top: 'auto',
},
};

function ToastContainer({ position, className }) {
const [toasts, setToasts] = useState([]);
const portalDivRef = useRef(null);
const positionStyle = positionStyles[position] || positionStyles['bottom-left'];

if (!portalDivRef.current) {
portalDivRef.current = document.createElement('div');
portalDivRef.current.setAttribute('class', 'toast-portal');
portalDivRef.current.setAttribute('role', 'alert');
portalDivRef.current.setAttribute('aria-live', 'polite');
portalDivRef.current.setAttribute('aria-atomic', 'true');
document.body.appendChild(portalDivRef.current);
}

render() {
if (this.rootElement) {
return ReactDOM.createPortal(
this.props.children,
this.rootElement,
const removeToast = (id) => {
setToasts(currentToasts => currentToasts.filter(toast => toast.id !== id));
};

useEffect(() => {
const handleShowToast = ({ message, duration, actions }) => {
const id = Date.now();
setToasts(currentToasts => [...currentToasts, {
id, message, duration, actions,
}]);
};

toastEmitter.subscribe('showToast', handleShowToast);

return () => {
toastEmitter.events.showToast = toastEmitter.events.showToast.filter(
callback => callback !== handleShowToast,
);
}
return null;
}
if (portalDivRef.current) {
document.body.removeChild(portalDivRef.current);
}
};
}, []);

return portalDivRef.current ? ReactDOM.createPortal(
<div className="toast-container" style={{ ...positionStyle }}>
{toasts.map(toast => (
<Toast key={toast.id} {...toast} onDismiss={() => removeToast(toast.id)} className={className} />
))}
</div>,
portalDivRef.current,
) : null;
}

export default ToastContainer;

ToastContainer.propTypes = {
/** Specifies contents of the component. */
children: PropTypes.node.isRequired,
position: PropTypes.oneOf(['top-left', 'top-right', 'bottom-left', 'bottom-right']),
className: PropTypes.string,
};

export default ToastContainer;
ToastContainer.defaultProps = {
position: 'bottom-left',
className: '',
};
Loading

0 comments on commit 1517acf

Please sign in to comment.