-
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.
Create reusable ToastMessages component
- Loading branch information
Showing
6 changed files
with
705 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
import classnames from 'classnames'; | ||
import type { | ||
ComponentChildren, | ||
ComponentProps, | ||
FunctionComponent, | ||
} from 'preact'; | ||
import { | ||
useCallback, | ||
useLayoutEffect, | ||
useMemo, | ||
useRef, | ||
useState, | ||
} from 'preact/hooks'; | ||
|
||
import type { TransitionComponent } from '../../types'; | ||
import Callout from './Callout'; | ||
|
||
export type ToastMessage = { | ||
id: string; | ||
type: 'error' | 'success' | 'notice'; | ||
message: ComponentChildren; | ||
|
||
/** | ||
* Visually hidden messages are announced to screen readers but not visible. | ||
* Defaults to false. | ||
*/ | ||
visuallyHidden?: boolean; | ||
|
||
/** | ||
* Determines if the toast message should be auto-dismissed. | ||
* Defaults to true. | ||
*/ | ||
autoDismiss?: boolean; | ||
}; | ||
|
||
export type ToastMessageTransitionClasses = { | ||
/** Classes to apply to a toast message when appended. Defaults to 'animate-fade-in' */ | ||
transitionIn?: string; | ||
/** Classes to apply to a toast message being dismissed. Defaults to 'animate-fade-out' */ | ||
transitionOut?: string; | ||
}; | ||
|
||
type ToastMessageItemProps = { | ||
message: ToastMessage; | ||
onDismiss: (id: string) => void; | ||
}; | ||
|
||
/** | ||
* An individual toast message: a brief and transient success or error message. | ||
* The message may be dismissed by clicking on it. `visuallyHidden` toast | ||
* messages will not be visible but are still available to screen readers. | ||
*/ | ||
function ToastMessageItem({ message, onDismiss }: ToastMessageItemProps) { | ||
// Capitalize the message type for prepending; Don't prepend a message | ||
// type for "notice" messages | ||
const prefix = | ||
message.type !== 'notice' | ||
? `${message.type.charAt(0).toUpperCase() + message.type.slice(1)}: ` | ||
: ''; | ||
|
||
return ( | ||
<Callout | ||
classes={classnames({ | ||
'sr-only': message.visuallyHidden, | ||
})} | ||
status={message.type} | ||
onClick={() => onDismiss(message.id)} | ||
variant="raised" | ||
> | ||
<strong>{prefix}</strong> | ||
{message.message} | ||
</Callout> | ||
); | ||
} | ||
|
||
type ToastMessageTransitionType = FunctionComponent< | ||
ComponentProps<TransitionComponent> & { | ||
transitionClasses?: ToastMessageTransitionClasses; | ||
} | ||
>; | ||
|
||
const ToastMessageTransition: ToastMessageTransitionType = ({ | ||
direction, | ||
onTransitionEnd, | ||
children, | ||
transitionClasses = {}, | ||
}) => { | ||
const isDismissed = direction === 'out'; | ||
const containerRef = useRef<HTMLDivElement>(null); | ||
const handleAnimation = (e: AnimationEvent) => { | ||
// Ignore animations happening on child elements | ||
if (e.target !== containerRef.current) { | ||
return; | ||
} | ||
|
||
onTransitionEnd?.(direction ?? 'in'); | ||
}; | ||
const classes = useMemo(() => { | ||
const { | ||
transitionIn = 'animate-fade-in', | ||
transitionOut = 'animate-fade-out', | ||
} = transitionClasses; | ||
|
||
return { | ||
[transitionIn]: !isDismissed, | ||
[transitionOut]: isDismissed, | ||
}; | ||
}, [isDismissed, transitionClasses]); | ||
|
||
return ( | ||
<div | ||
data-testid="animation-container" | ||
onAnimationEnd={handleAnimation} | ||
ref={containerRef} | ||
className={classnames('relative w-full container', classes)} | ||
> | ||
{children} | ||
</div> | ||
); | ||
}; | ||
|
||
export type ToastMessagesProps = { | ||
messages: ToastMessage[]; | ||
onMessageDismiss: (id: string) => void; | ||
transitionClasses?: ToastMessageTransitionClasses; | ||
setTimeout_?: typeof setTimeout; | ||
}; | ||
|
||
type TimeoutId = number; | ||
|
||
/** | ||
* A collection of toast messages. These are rendered within an `aria-live` | ||
* region for accessibility with screen readers. | ||
*/ | ||
export default function ToastMessages({ | ||
messages, | ||
onMessageDismiss, | ||
transitionClasses, | ||
/* istanbul ignore next - test seam */ | ||
setTimeout_ = setTimeout, | ||
}: ToastMessagesProps) { | ||
// List of IDs of toast messages that have been dismissed and have an | ||
// in-progress 'out' transition | ||
const [dismissedMessages, setDismissedMessages] = useState<string[]>([]); | ||
// Tracks not finished timeouts for auto-dismiss toast messages | ||
const messageSchedules = useRef(new Map<string, TimeoutId>()); | ||
|
||
const dismissMessage = useCallback( | ||
(id: string) => setDismissedMessages(ids => [...ids, id]), | ||
[], | ||
); | ||
const scheduleMessageDismiss = useCallback( | ||
(id: string) => { | ||
const timeout = setTimeout_(() => { | ||
dismissMessage(id); | ||
messageSchedules.current.delete(id); | ||
}, 5000); | ||
messageSchedules.current.set(id, timeout); | ||
}, | ||
[dismissMessage, setTimeout_], | ||
); | ||
|
||
const onTransitionEnd = useCallback( | ||
(direction: 'in' | 'out', message: ToastMessage) => { | ||
const autoDismiss = message.autoDismiss ?? true; | ||
if (direction === 'in' && autoDismiss) { | ||
scheduleMessageDismiss(message.id); | ||
} | ||
|
||
if (direction === 'out') { | ||
onMessageDismiss(message.id); | ||
setDismissedMessages(ids => ids.filter(id => id !== message.id)); | ||
} | ||
}, | ||
[scheduleMessageDismiss, onMessageDismiss], | ||
); | ||
|
||
useLayoutEffect(() => { | ||
// Clear all pending timeouts for not yet dismissed toast messages when the | ||
// component is unmounted | ||
const pendingTimeouts = messageSchedules.current; | ||
return () => { | ||
pendingTimeouts.forEach(timeout => clearTimeout(timeout)); | ||
}; | ||
}, []); | ||
|
||
return ( | ||
<ul | ||
aria-live="polite" | ||
aria-relevant="additions" | ||
className="w-full space-y-2" | ||
data-component="ToastMessages" | ||
> | ||
{messages.map(message => { | ||
const isDismissed = dismissedMessages.includes(message.id); | ||
return ( | ||
<li | ||
className={classnames({ | ||
// Add a bottom margin to visible messages only. Typically, we'd | ||
// use a `space-y-2` class on the parent to space children. | ||
// Doing that here could cause an undesired top margin on | ||
// the first visible message in a list that contains (only) | ||
// visually-hidden messages before it. | ||
// See https://tailwindcss.com/docs/space#limitations | ||
'mb-2': !message.visuallyHidden, | ||
})} | ||
key={message.id} | ||
> | ||
<ToastMessageTransition | ||
direction={isDismissed ? 'out' : 'in'} | ||
onTransitionEnd={direction => onTransitionEnd(direction, message)} | ||
transitionClasses={transitionClasses} | ||
> | ||
<ToastMessageItem message={message} onDismiss={dismissMessage} /> | ||
</ToastMessageTransition> | ||
</li> | ||
); | ||
})} | ||
</ul> | ||
); | ||
} |
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,168 @@ | ||
import { mount } from 'enzyme'; | ||
|
||
import ToastMessages from '../ToastMessages'; | ||
|
||
describe('ToastMessages', () => { | ||
let wrappers; | ||
const toastMessages = [ | ||
{ | ||
id: '1', | ||
type: 'success', | ||
message: 'Hello world', | ||
}, | ||
{ | ||
id: '2', | ||
type: 'notice', | ||
message: 'Foobar', | ||
}, | ||
{ | ||
id: '3', | ||
type: 'error', | ||
message: 'Something failed', | ||
}, | ||
]; | ||
let fakeOnMessageDismiss; | ||
|
||
beforeEach(() => { | ||
wrappers = []; | ||
fakeOnMessageDismiss = sinon.stub(); | ||
}); | ||
|
||
afterEach(() => { | ||
wrappers.forEach(wrapper => wrapper.unmount()); | ||
}); | ||
|
||
function createToastMessages(toastMessages, setTimeout) { | ||
const container = document.createElement('div'); | ||
document.body.appendChild(container); | ||
|
||
const wrapper = mount( | ||
<ToastMessages | ||
messages={toastMessages} | ||
onMessageDismiss={fakeOnMessageDismiss} | ||
setTimeout_={setTimeout} | ||
/>, | ||
{ attachTo: container }, | ||
); | ||
wrappers.push(wrapper); | ||
|
||
return wrapper; | ||
} | ||
|
||
function triggerAnimationEnd(wrapper, index, direction = 'out') { | ||
wrapper | ||
.find('ToastMessageTransition') | ||
.at(index) | ||
.props() | ||
.onTransitionEnd(direction); | ||
wrapper.update(); | ||
} | ||
|
||
it('renders a list of toast messages', () => { | ||
const wrapper = createToastMessages(toastMessages); | ||
assert.equal(wrapper.find('ToastMessageItem').length, toastMessages.length); | ||
}); | ||
|
||
toastMessages.forEach((message, index) => { | ||
it('dismisses messages when clicked', () => { | ||
const wrapper = createToastMessages(toastMessages); | ||
|
||
wrapper.find('Callout').at(index).props().onClick(); | ||
// onMessageDismiss is not immediately called. Transition has to finish | ||
assert.notCalled(fakeOnMessageDismiss); | ||
|
||
// Once dismiss animation has finished, onMessageDismiss is called | ||
triggerAnimationEnd(wrapper, index); | ||
assert.calledWith(fakeOnMessageDismiss, message.id); | ||
}); | ||
}); | ||
|
||
it('dismisses messages automatically unless instructed otherwise', () => { | ||
const messages = [ | ||
...toastMessages, | ||
{ | ||
id: 'foo', | ||
type: 'success', | ||
message: 'Not to be dismissed', | ||
autoDismiss: false, | ||
}, | ||
]; | ||
const wrapper = createToastMessages( | ||
messages, | ||
// Fake internal setTimeout, to immediately call its callback | ||
callback => callback(), | ||
); | ||
|
||
// Trigger "in" animation for all messages, which will schedule dismiss for | ||
// appropriate messages | ||
messages.forEach((_, index) => { | ||
triggerAnimationEnd(wrapper, index, 'in'); | ||
}); | ||
|
||
// Trigger "out" animation on components whose "direction" prop is currently | ||
// "out". That means they were scheduled for dismiss | ||
wrapper | ||
.find('ToastMessageTransition') | ||
.forEach((transitionComponent, index) => { | ||
if (transitionComponent.prop('direction') === 'out') { | ||
triggerAnimationEnd(wrapper, index); | ||
} | ||
}); | ||
|
||
// Only one toast message will remain, as it was marked as `autoDismiss: false` | ||
assert.equal(fakeOnMessageDismiss.callCount, 3); | ||
}); | ||
|
||
it('schedules dismiss only once per message', () => { | ||
const wrapper = createToastMessages( | ||
toastMessages, | ||
// Fake an immediate setTimeout which does not slow down the test, but | ||
// keeps the async behavior | ||
callback => setTimeout(callback, 0), | ||
); | ||
const scheduleFirstMessageDismiss = () => | ||
triggerAnimationEnd(wrapper, 0, 'in'); | ||
|
||
scheduleFirstMessageDismiss(); | ||
scheduleFirstMessageDismiss(); | ||
scheduleFirstMessageDismiss(); | ||
|
||
// Once dismiss animation has finished, onMessageDismiss is called | ||
triggerAnimationEnd(wrapper, 0); | ||
assert.equal(fakeOnMessageDismiss.callCount, 1); | ||
}); | ||
|
||
it('invokes onTransitionEnd when animation happens on container', () => { | ||
const wrapper = createToastMessages(toastMessages, callback => callback()); | ||
const animationContainer = wrapper | ||
.find('[data-testid="animation-container"]') | ||
.first(); | ||
|
||
// Trigger "in" animation for all messages, which will schedule dismiss | ||
toastMessages.forEach((_, index) => { | ||
triggerAnimationEnd(wrapper, index, 'in'); | ||
}); | ||
|
||
animationContainer | ||
.getDOMNode() | ||
.dispatchEvent(new AnimationEvent('animationend')); | ||
|
||
assert.called(fakeOnMessageDismiss); | ||
}); | ||
|
||
it('does not invoke onTransitionEnd for animation events bubbling from children', () => { | ||
const wrapper = createToastMessages(toastMessages, callback => callback()); | ||
const child = wrapper.find('Callout').first(); | ||
|
||
// Trigger "in" animation for all messages, which will schedule dismiss | ||
toastMessages.forEach((_, index) => { | ||
triggerAnimationEnd(wrapper, index, 'in'); | ||
}); | ||
|
||
child | ||
.getDOMNode() | ||
.dispatchEvent(new AnimationEvent('animationend', { bubbles: true })); | ||
|
||
assert.notCalled(fakeOnMessageDismiss); | ||
}); | ||
}); |
Oops, something went wrong.