Skip to content

Commit

Permalink
Create reusable ToastMessages component
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 27, 2023
1 parent 217e4e4 commit 7baa8c4
Show file tree
Hide file tree
Showing 6 changed files with 705 additions and 0 deletions.
221 changes: 221 additions & 0 deletions src/components/feedback/ToastMessages.tsx
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>
);
}
168 changes: 168 additions & 0 deletions src/components/feedback/test/ToastMessages-test.js
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);
});
});
Loading

0 comments on commit 7baa8c4

Please sign in to comment.