Skip to content

Commit 5ed1fc5

Browse files
authored
Added Toast component (react-bootstrap#3685)
* feat: added Toast component * more changes * cleaned up * added some docs * feat: added fade in fade out * feat: added fade in fade out * feat: autohide feature * fix: cleanup * fix: warnings * fix: added typings * feat: added unit tests * fix: tests * fix: tests * fix: migrated ToastDialog into Toast component * fix: replaced placeholders by holder.js * fix: moved setInterval out of Wndows * fix: types * fix: prop-types formatting * fix: removed aria-label prop on CloseButton * fix: adjusted coding related to the review * fix: added clearing of the Timeouts * Update src/Toast.js Co-Authored-By: mxschmitt <[email protected]>
1 parent 0f275b9 commit 5ed1fc5

20 files changed

+601
-6
lines changed

src/CloseButton.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import PropTypes from 'prop-types';
22
import React from 'react';
3+
import classNames from 'classnames';
34

45
const propTypes = {
56
label: PropTypes.string.isRequired,
@@ -10,12 +11,20 @@ const defaultProps = {
1011
label: 'Close',
1112
};
1213

13-
const CloseButton = React.forwardRef(({ label, onClick }, ref) => (
14-
<button ref={ref} type="button" className="close" onClick={onClick}>
15-
<span aria-hidden="true">&times;</span>
16-
<span className="sr-only">{label}</span>
17-
</button>
18-
));
14+
const CloseButton = React.forwardRef(
15+
({ label, onClick, className, ...props }, ref) => (
16+
<button
17+
ref={ref}
18+
type="button"
19+
className={classNames('close', className)}
20+
onClick={onClick}
21+
{...props}
22+
>
23+
<span aria-hidden="true">&times;</span>
24+
<span className="sr-only">{label}</span>
25+
</button>
26+
),
27+
);
1928

2029
CloseButton.displayName = 'CloseButton';
2130
CloseButton.propTypes = propTypes;

src/Toast.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { useEffect } from 'react';
2+
import PropTypes from 'prop-types';
3+
import classNames from 'classnames';
4+
5+
import Fade from './Fade';
6+
import Header from './ToastHeader';
7+
import Body from './ToastBody';
8+
import { createBootstrapComponent } from './ThemeProvider';
9+
import ToastContext from './ToastContext';
10+
11+
const propTypes = {
12+
/**
13+
* @default 'toast'
14+
*/
15+
bsPrefix: PropTypes.string,
16+
17+
/**
18+
* Apply a CSS fade transition to the toast
19+
*/
20+
animation: PropTypes.bool,
21+
22+
/**
23+
* Auto hide the toast
24+
*/
25+
autohide: PropTypes.bool,
26+
27+
/**
28+
* Delay hiding the toast (ms)
29+
*/
30+
delay: PropTypes.number,
31+
32+
/**
33+
* A Callback fired when the close button is clicked.
34+
*/
35+
onClose: PropTypes.func,
36+
37+
/**
38+
* When `true` The modal will show itself.
39+
*/
40+
show: PropTypes.bool,
41+
42+
/**
43+
* A `react-transition-group` Transition component used to animate the Toast on dismissal.
44+
*/
45+
transition: PropTypes.elementType,
46+
47+
/** @ignore */
48+
innerRef: PropTypes.any,
49+
};
50+
51+
const defaultProps = {
52+
animation: true,
53+
autohide: false,
54+
delay: 3000,
55+
show: true,
56+
transition: Fade,
57+
};
58+
59+
const Toast = ({
60+
bsPrefix,
61+
className,
62+
children,
63+
transition: Transition,
64+
show,
65+
animation,
66+
delay,
67+
autohide,
68+
onClose,
69+
innerRef,
70+
...props
71+
}) => {
72+
useEffect(() => {
73+
if (autohide && show) {
74+
const timer = setTimeout(() => {
75+
onClose();
76+
}, delay);
77+
return () => {
78+
clearTimeout(timer);
79+
};
80+
}
81+
return () => null;
82+
}, [autohide, show]);
83+
const useAnimation = Transition && animation;
84+
const toast = (
85+
<div
86+
{...props}
87+
ref={innerRef}
88+
className={classNames(
89+
bsPrefix,
90+
className,
91+
!useAnimation && show && 'show',
92+
)}
93+
role="alert"
94+
aria-live="assertive"
95+
aria-atomic="true"
96+
>
97+
{children}
98+
</div>
99+
);
100+
101+
const toastContext = {
102+
onClose,
103+
};
104+
105+
return (
106+
<ToastContext.Provider value={toastContext}>
107+
{useAnimation ? <Transition in={show}>{toast}</Transition> : toast}
108+
</ToastContext.Provider>
109+
);
110+
};
111+
112+
Toast.propTypes = propTypes;
113+
Toast.defaultProps = defaultProps;
114+
115+
const DecoratedToast = createBootstrapComponent(Toast, 'toast');
116+
117+
DecoratedToast.Body = Body;
118+
DecoratedToast.Header = Header;
119+
120+
export default DecoratedToast;

src/ToastBody.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import createWithBsPrefix from './utils/createWithBsPrefix';
2+
3+
export default createWithBsPrefix('toast-body');

src/ToastContext.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React from 'react';
2+
3+
const ToastContext = React.createContext({
4+
onClose() {},
5+
});
6+
7+
export default ToastContext;

src/ToastHeader.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import classNames from 'classnames';
2+
import PropTypes from 'prop-types';
3+
import React, { useContext } from 'react';
4+
import useEventCallback from '@restart/hooks/useEventCallback';
5+
6+
import { useBootstrapPrefix } from './ThemeProvider';
7+
import CloseButton from './CloseButton';
8+
import ToastContext from './ToastContext';
9+
10+
const propTypes = {
11+
bsPrefix: PropTypes.string,
12+
13+
/**
14+
* Provides an accessible label for the close
15+
* button. It is used for Assistive Technology when the label text is not
16+
* readable.
17+
*/
18+
closeLabel: PropTypes.string,
19+
20+
/**
21+
* Specify whether the Component should contain a close button
22+
*/
23+
closeButton: PropTypes.bool,
24+
};
25+
26+
const defaultProps = {
27+
closeLabel: 'Close',
28+
closeButton: true,
29+
};
30+
31+
const ToastHeader = ({
32+
bsPrefix,
33+
closeLabel,
34+
closeButton,
35+
className,
36+
children,
37+
...props
38+
}) => {
39+
bsPrefix = useBootstrapPrefix(bsPrefix, 'toast-header');
40+
41+
const context = useContext(ToastContext);
42+
43+
const handleClick = useEventCallback(() => {
44+
if (context) {
45+
context.onClose();
46+
}
47+
});
48+
49+
return (
50+
<div {...props} className={classNames(bsPrefix, className)}>
51+
{children}
52+
53+
{closeButton && (
54+
<CloseButton
55+
label={closeLabel}
56+
onClick={handleClick}
57+
className="ml-2 mb-1"
58+
data-dismiss="toast"
59+
/>
60+
)}
61+
</div>
62+
);
63+
};
64+
65+
ToastHeader.displayName = 'ToastHeader';
66+
ToastHeader.propTypes = propTypes;
67+
ToastHeader.defaultProps = defaultProps;
68+
69+
export default ToastHeader;

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,6 @@ export ThemeProvider from './ThemeProvider';
6767
export ToggleButton from './ToggleButton';
6868
export ToggleButtonGroup from './ToggleButtonGroup';
6969
export Tooltip from './Tooltip';
70+
export Toast from './Toast';
71+
export ToastBody from './ToastBody';
72+
export ToastHeader from './ToastHeader';

test/ToastBodySpec.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
4+
import Toast from '../src/Toast';
5+
6+
describe('Toast.Body', () => {
7+
it('will pass all props to the created div and renders its children', () => {
8+
const content = <strong>Content</strong>;
9+
mount(
10+
<Toast.Body className="custom-class">{content}</Toast.Body>,
11+
).assertSingle('div.custom-class.toast-body>strong');
12+
});
13+
});

test/ToastHeaderSpec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
4+
import Toast from '../src/Toast';
5+
6+
describe('Toast.Header', () => {
7+
it('will pass all props to the created div and renders its children', () => {
8+
mount(
9+
<Toast.Header>
10+
<strong>content</strong>
11+
</Toast.Header>,
12+
).assertSingle('div.toast-header strong');
13+
});
14+
});

test/ToastSpec.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
4+
import Toast from '../src/Toast';
5+
6+
describe('Toasts', () => {
7+
it('will render an entire toast', () => {
8+
mount(
9+
<Toast>
10+
<Toast.Header />
11+
<Toast.Body />
12+
</Toast>,
13+
).assertSingle(
14+
'div.toast[role="alert"][aria-live="assertive"][aria-atomic="true"]',
15+
);
16+
});
17+
18+
it('should trigger the onClose event after clicking on the close button', () => {
19+
let onCloseSpy = sinon.spy();
20+
mount(
21+
<Toast onClose={onCloseSpy}>
22+
<Toast.Header>header-content</Toast.Header>
23+
<Toast.Body>body-content</Toast.Body>
24+
</Toast>,
25+
)
26+
.find('.toast-header')
27+
.at(0)
28+
.find('button')
29+
.simulate('click');
30+
31+
expect(onCloseSpy).to.be.calledOnce;
32+
});
33+
34+
it('should trigger the onClose event after the autohide delay', () => {
35+
const clock = sinon.useFakeTimers({
36+
toFake: ['setTimeout'],
37+
});
38+
const onCloseSpy = sinon.spy();
39+
mount(
40+
<Toast onClose={onCloseSpy} delay={500} show autohide>
41+
<Toast.Header>header-content</Toast.Header>
42+
<Toast.Body>body-content</Toast.Body>
43+
</Toast>,
44+
);
45+
clock.tick(500);
46+
expect(onCloseSpy).to.be.calledOnce;
47+
});
48+
});

types/components/Toast.d.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react';
2+
3+
import ToastBody from './ToastBody';
4+
import ToastHeader from './ToastHeader';
5+
6+
import {
7+
BsPrefixComponent,
8+
TransitionCallbacks,
9+
} from './helpers';
10+
11+
export interface ToastProps extends TransitionCallbacks {
12+
animation?: boolean;
13+
autohide?: boolean;
14+
delay?: number;
15+
onClose?: () => void;
16+
show: boolean;
17+
transition: boolean | React.ElementType,
18+
}
19+
20+
declare class Toast extends BsPrefixComponent<'div', ToastProps> {
21+
static Body: typeof ToastBody;
22+
static Header: typeof ToastHeader;
23+
}
24+
25+
export default Toast;

types/components/ToastBody.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as React from 'react';
2+
3+
import { BsPrefixComponent } from './helpers';
4+
5+
declare class ToastBody<
6+
As extends React.ReactType = 'div'
7+
> extends BsPrefixComponent<As> { }
8+
9+
export default ToastBody;

types/components/ToastHeader.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as React from 'react';
2+
3+
import { BsPrefixComponent } from './helpers';
4+
5+
export interface ToastHeaderProps {
6+
closeLabel?: string;
7+
closeButton?: boolean;
8+
}
9+
10+
declare class ToastHeader<
11+
As extends React.ReactType = 'div'
12+
> extends BsPrefixComponent<As, ToastHeaderProps> { }
13+
14+
export default ToastHeader;

www/src/components/SideNav.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ const components = [
132132
'table',
133133
'tabs',
134134
'tooltips',
135+
'toasts',
135136
];
136137

137138
const utilities = ['transitions', 'responsive-embed', 'react-overlays'];

0 commit comments

Comments
 (0)