Skip to content

Commit 3e94bf1

Browse files
committed
feat(toasts): implement toast components
1 parent e8061ca commit 3e94bf1

10 files changed

+301
-33
lines changed

demo/ToastsExamples.jsx

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React, { useContext } from 'react';
2+
import { ToastsContext } from '../dist/main';
3+
4+
export function ToastsExamples() {
5+
const toastsState = useContext(ToastsContext);
6+
7+
return (
8+
<div className="row">
9+
<div className="col-6 mb-5">
10+
<h1 className="h4">Top left </h1>
11+
<button
12+
className="btn btn-info"
13+
onClick={() => {
14+
toastsState.show('Top left toast!', {
15+
position: 'TOP_LEFT',
16+
});
17+
}}
18+
>
19+
Show top left toast
20+
</button>
21+
</div>
22+
<div className="col-6 mb-5">
23+
<h1 className="h4">Top right (default)</h1>
24+
<button
25+
className="btn btn-success"
26+
onClick={() => {
27+
toastsState.show('Top right toast!', { autoClose: false, type: 'success' });
28+
}}
29+
>
30+
Show top right toast
31+
</button>
32+
</div>
33+
<div className="col-6 mb-5">
34+
<h1 className="h4">Bottom left </h1>
35+
<button
36+
className="btn btn-danger"
37+
onClick={() => {
38+
toastsState.show('Bottom left toast!', {
39+
position: 'BOTTOM_LEFT',
40+
type: 'danger',
41+
});
42+
}}
43+
>
44+
Show bottom left toast
45+
</button>
46+
</div>
47+
<div className="col-6 mb-5">
48+
<h1 className="h4">Bottom right </h1>
49+
<button
50+
className="btn btn-warning"
51+
onClick={() => {
52+
toastsState.show('Bottom right toast!', {
53+
position: 'BOTTOM_RIGHT',
54+
type: 'warning',
55+
});
56+
}}
57+
>
58+
Show bottom right toast
59+
</button>
60+
</div>
61+
</div>
62+
);
63+
}

demo/demo.jsx

+38-31
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,53 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
33

4-
import { StatefulTabs, Pagination } from '../dist/main';
4+
import { StatefulTabs, Pagination, ToastsContainer } from '../dist/main';
55
import { FormExamples } from './FormExamples';
66
import { TableExamples } from './TableExamples';
77
import { TabsExamples } from './TabsExamples';
88
import { DialogExamples } from './DialogExamples';
99
import { ListGroupExamples } from './ListGroupExamples';
10+
import { ToastsExamples } from './ToastsExamples';
1011

1112
ReactDOM.render(
1213
<div className="mt-3">
1314
<React.StrictMode>
14-
<StatefulTabs
15-
vertical={true}
16-
initialTab={1}
17-
tabs={[
18-
{
19-
title: 'Dialog',
20-
content: <DialogExamples />,
21-
},
22-
{
23-
title: 'Forms',
24-
content: <FormExamples />,
25-
},
26-
{
27-
title: 'List groups',
28-
content: <ListGroupExamples />,
29-
},
30-
{
31-
title: 'Pagination',
32-
content: <PaginationExamples />,
33-
},
34-
{
35-
title: 'Tables',
36-
content: <TableExamples />,
37-
},
38-
{
39-
title: 'Tabs',
40-
content: <TabsExamples />,
41-
},
42-
]}
43-
/>
15+
<ToastsContainer>
16+
<StatefulTabs
17+
vertical={true}
18+
initialTab={6}
19+
tabs={[
20+
{
21+
title: 'Dialog',
22+
content: <DialogExamples />,
23+
},
24+
{
25+
title: 'Forms',
26+
content: <FormExamples />,
27+
},
28+
{
29+
title: 'List groups',
30+
content: <ListGroupExamples />,
31+
},
32+
{
33+
title: 'Pagination',
34+
content: <PaginationExamples />,
35+
},
36+
{
37+
title: 'Tables',
38+
content: <TableExamples />,
39+
},
40+
{
41+
title: 'Tabs',
42+
content: <TabsExamples />,
43+
},
44+
{
45+
title: 'Toasts',
46+
content: <ToastsExamples />,
47+
},
48+
]}
49+
/>
50+
</ToastsContainer>
4451
</React.StrictMode>
4552
</div>,
4653
document.getElementById('root')

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './list-group';
44
export * from './mixed';
55
export * from './table';
66
export * from './tabs';
7+
export * from './toasts';

src/toasts/Toast.jsx

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React, { useContext } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { formatClasses } from '../utils/attributes';
4+
import { ToastsContext } from './toasts-helpers';
5+
6+
export function Toast({ id, type, message, closeControl, position, noStyle }) {
7+
const toastsState = useContext(ToastsContext);
8+
9+
return (
10+
<div
11+
className={formatClasses(['toast-message', 'alert', `alert-${type}`, closeControl && 'alert-dismissible'])}
12+
style={noStyle ? null : { width: 'auto', zIndex: 9999 }}
13+
>
14+
{message}
15+
16+
{closeControl && (
17+
<button type="button" className="close" onClick={() => toastsState.close(position, id)} aria-label="Close">
18+
<span aria-hidden="true">&times;</span>
19+
</button>
20+
)}
21+
</div>
22+
);
23+
}
24+
25+
Toast.propTypes = {
26+
closeControl: PropTypes.bool,
27+
id: PropTypes.number.isRequired,
28+
message: PropTypes.string.isRequired,
29+
noStyle: PropTypes.bool,
30+
position: PropTypes.string.isRequired,
31+
type: PropTypes.string.isRequired,
32+
};

src/toasts/ToastsContainer.jsx

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { ToastsContext, toastPositions } from './toasts-helpers';
4+
import { ToastsRegion } from './ToastsRegion';
5+
import { useToast } from './useToast';
6+
7+
export function ToastsContainer({ children, unique, noStyle }) {
8+
const toastsState = useToast({ unique });
9+
10+
return (
11+
<ToastsContext.Provider value={toastsState}>
12+
{children}
13+
14+
<div className="toast-container">
15+
{toastPositions.map(({ name, className }) => (
16+
<ToastsRegion key={name} name={name} className={className} toasts={toastsState.get(name)} noStyle={noStyle} />
17+
))}
18+
</div>
19+
</ToastsContext.Provider>
20+
);
21+
}
22+
23+
ToastsContainer.defaultProps = {
24+
noStyle: false,
25+
unique: true,
26+
};
27+
28+
ToastsContainer.propTypes = {
29+
children: PropTypes.element,
30+
noStyle: PropTypes.bool,
31+
unique: PropTypes.bool,
32+
};

src/toasts/ToastsRegion.jsx

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { Toast } from './Toast';
4+
import { toastsDefaultStylesByPosition } from './toasts-helpers';
5+
6+
export function ToastsRegion({ name, className, toasts, noStyle }) {
7+
return (
8+
<div
9+
key={name}
10+
className={className}
11+
style={
12+
noStyle
13+
? null
14+
: {
15+
position: 'fixed',
16+
zIndex: 9999,
17+
maxWidth: '50%',
18+
...toastsDefaultStylesByPosition[name],
19+
}
20+
}
21+
>
22+
{toasts.map((toast) => (
23+
<Toast key={toast.id} {...toast} />
24+
))}
25+
</div>
26+
);
27+
}
28+
29+
ToastsRegion.defaultProps = {
30+
toasts: [],
31+
};
32+
33+
ToastsRegion.propTypes = {
34+
className: PropTypes.string.isRequired,
35+
name: PropTypes.string.isRequired,
36+
noStyle: PropTypes.bool,
37+
toasts: PropTypes.arrayOf(PropTypes.object),
38+
};

src/toasts/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './toasts-helpers';
2+
export * from './ToastsContainer';

src/toasts/toasts-helpers.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
3+
export const ToastsContext = React.createContext(null);
4+
5+
export const toastPositions = [
6+
{
7+
name: 'TOP_LEFT',
8+
className: 'top-left',
9+
},
10+
{
11+
name: 'TOP_RIGHT',
12+
className: 'top-right',
13+
},
14+
{
15+
name: 'BOTTOM_LEFT',
16+
className: 'bottom-left',
17+
},
18+
{
19+
name: 'BOTTOM_RIGHT',
20+
className: 'bottom-right',
21+
},
22+
];
23+
24+
export const toastsDefaultStylesByPosition = {
25+
TOP_RIGHT: {
26+
top: '25px',
27+
right: '25px',
28+
},
29+
TOP_LEFT: {
30+
top: '25px',
31+
left: '25px',
32+
},
33+
BOTTOM_RIGHT: {
34+
right: '25px',
35+
bottom: '25px',
36+
},
37+
BOTTOM_LEFT: {
38+
left: '25px',
39+
bottom: '25px',
40+
},
41+
};

src/toasts/useToast.js

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useState } from 'react';
2+
import { useArrayValueMap } from '../utils/useValueMap';
3+
4+
export function useToast({ unique }) {
5+
const [nextId, setNextId] = useState(0);
6+
const { push, unset, get } = useArrayValueMap(
7+
unique && {
8+
equalityComparator: (a) => (b) => {
9+
return a.message === b.message;
10+
},
11+
}
12+
);
13+
14+
function close(position, toastId) {
15+
unset(position, (toast) => {
16+
return toast.id !== toastId;
17+
});
18+
}
19+
20+
return {
21+
show(message, { type = 'info', autoClose = 5000, position = 'TOP_RIGHT' } = {}) {
22+
if (!['info', 'success', 'danger', 'warning'].includes(type)) {
23+
throw new Error(`Invalid toast type ${type}. Must be 'info', 'success', 'danger' or 'warning'`);
24+
}
25+
26+
const toastId = nextId;
27+
28+
push(position, {
29+
id: toastId,
30+
message,
31+
type,
32+
position,
33+
closeControl: !autoClose,
34+
});
35+
36+
if (autoClose && typeof autoClose === 'number' && !isNaN(autoClose)) {
37+
setTimeout(() => {
38+
close(position, toastId);
39+
}, autoClose);
40+
}
41+
42+
setNextId((prevId) => prevId + 1);
43+
},
44+
close,
45+
get,
46+
};
47+
}

src/utils/useValueMap.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function useValueMap() {
3636
};
3737
}
3838

39-
export function useArrayValueMap({ unique = true } = {}) {
39+
export function useArrayValueMap({ unique = true, equalityComparator = (a) => (b) => a === b } = {}) {
4040
const { getAllKeys, getValue, setValue } = useValueMap();
4141

4242
return {
@@ -48,13 +48,18 @@ export function useArrayValueMap({ unique = true } = {}) {
4848
values = [];
4949
}
5050

51-
if (!unique || !values.includes(value)) {
51+
if (!unique || !values.find(equalityComparator(value))) {
5252
values.push(value);
5353
}
5454

5555
return values;
5656
});
5757
},
58+
unset(key, filterfn) {
59+
setValue(key, (prevValues) => {
60+
return prevValues.filter(filterfn);
61+
});
62+
},
5863
get: getValue,
5964
getAllKeys,
6065
};

0 commit comments

Comments
 (0)