Commit 5b096a7c authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Use ToastMessages from frontend-shared

parent 64ff836d
import { ToastMessages as BaseToastMessages } from '@hypothesis/frontend-shared';
import type { ToastMessage } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks';
import BaseToastMessages from '../../shared/components/ToastMessages';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import type { Emitter } from '../util/emitter'; import type { Emitter } from '../util/emitter';
export type ToastMessagesProps = { export type ToastMessagesProps = {
......
import type { ToastMessage } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; import classnames from 'classnames';
import * as Hammer from 'hammerjs'; import * as Hammer from 'hammerjs';
import { render } from 'preact'; import { render } from 'preact';
import type { ToastMessage } from '../shared/components/ToastMessages';
import { addConfigFragment } from '../shared/config-fragment'; import { addConfigFragment } from '../shared/config-fragment';
import { sendErrorsTo } from '../shared/frame-error-capture'; import { sendErrorsTo } from '../shared/frame-error-capture';
import { ListenerCollection } from '../shared/listener-collection'; import { ListenerCollection } from '../shared/listener-collection';
......
import type { TransitionComponent } from '@hypothesis/frontend-shared';
import { Callout } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import type {
ComponentChildren,
ComponentProps,
FunctionComponent,
} from 'preact';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
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 BaseToastMessageTransitionType = FunctionComponent<
ComponentProps<TransitionComponent> & {
transitionClasses?: ToastMessageTransitionClasses;
}
>;
const BaseToastMessageTransition: BaseToastMessageTransitionType = ({
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;
};
/**
* 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) {
const [dismissedMessages, setDismissedMessages] = useState<string[]>([]);
const scheduledMessages = useRef(new Set<string>());
const dismissMessage = useCallback(
(id: string) => setDismissedMessages(ids => [...ids, id]),
[],
);
const scheduleMessageDismiss = useCallback(
(id: string) => {
if (scheduledMessages.current.has(id)) {
return;
}
// Track that this message has been scheduled to be dismissed. After a
// period of time, actually dismiss it
scheduledMessages.current.add(id);
setTimeout_(() => {
dismissMessage(id);
scheduledMessages.current.delete(id);
}, 5000);
},
[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],
);
return (
<ul
aria-live="polite"
aria-relevant="additions"
className="w-full space-y-2"
>
{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}
>
<BaseToastMessageTransition
direction={isDismissed ? 'out' : 'in'}
onTransitionEnd={direction => onTransitionEnd(direction, message)}
transitionClasses={transitionClasses}
>
<ToastMessageItem message={message} onDismiss={dismissMessage} />
</BaseToastMessageTransition>
</li>
);
})}
</ul>
);
}
import { mount } from 'enzyme';
import ToastMessages from '../ToastMessages';
describe('ToastMessages', () => {
const toastMessages = [
{
id: '1',
type: 'success',
message: 'Hello world',
},
{
id: '2',
type: 'success',
message: 'Foobar',
},
{
id: '3',
type: 'error',
message: 'Something failed',
},
];
let fakeOnMessageDismiss;
beforeEach(() => {
fakeOnMessageDismiss = sinon.stub();
});
function createToastMessages(toastMessages, setTimeout) {
const container = document.createElement('div');
document.body.appendChild(container);
return mount(
<ToastMessages
messages={toastMessages}
onMessageDismiss={fakeOnMessageDismiss}
setTimeout_={setTimeout}
/>,
{ attachTo: container },
);
}
function triggerAnimationEnd(wrapper, index, direction = 'out') {
wrapper
.find('BaseToastMessageTransition')
.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 which "direction" prop is currently
// "out". That means they were scheduled for dismiss
wrapper
.find('BaseToastMessageTransition')
.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', async () => {
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 invalidAnimationContainer = wrapper.find('Callout').first();
// Trigger "in" animation for all messages, which will schedule dismiss
toastMessages.forEach((_, index) => {
triggerAnimationEnd(wrapper, index, 'in');
});
invalidAnimationContainer
.getDOMNode()
.dispatchEvent(new AnimationEvent('animationend', { bubbles: true }));
assert.notCalled(fakeOnMessageDismiss);
});
});
import { ToastMessages as BaseToastMessages } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; import classnames from 'classnames';
import BaseToastMessages from '../../shared/components/ToastMessages';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
import type { ToastMessengerService } from '../services/toast-messenger'; import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../store';
......
import type { ToastMessage } from '@hypothesis/frontend-shared';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import type { DebouncedFunction } from 'lodash.debounce'; import type { DebouncedFunction } from 'lodash.debounce';
import shallowEqual from 'shallowequal'; import shallowEqual from 'shallowequal';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import { ListenerCollection } from '../../shared/listener-collection'; import { ListenerCollection } from '../../shared/listener-collection';
import { import {
PortFinder, PortFinder,
......
import type { ToastMessage } from '@hypothesis/frontend-shared';
import { TinyEmitter } from 'tiny-emitter'; import { TinyEmitter } from 'tiny-emitter';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import { generateHexString } from '../../shared/random'; import { generateHexString } from '../../shared/random';
import type { SidebarStore } from '../store'; import type { SidebarStore } from '../store';
......
import type { ToastMessage } from '../../../shared/components/ToastMessages'; import type { ToastMessage } from '@hypothesis/frontend-shared';
import { createStoreModule, makeAction } from '../create-store'; import { createStoreModule, makeAction } from '../create-store';
/** /**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment