diff --git a/src/annotator/components/ToastMessages.tsx b/src/annotator/components/ToastMessages.tsx
index 74a78e4de23..2ce02371649 100644
--- a/src/annotator/components/ToastMessages.tsx
+++ b/src/annotator/components/ToastMessages.tsx
@@ -1,7 +1,7 @@
+import { ToastMessages as BaseToastMessages } from '@hypothesis/frontend-shared';
+import type { ToastMessage } from '@hypothesis/frontend-shared';
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';
export type ToastMessagesProps = {
diff --git a/src/annotator/sidebar.tsx b/src/annotator/sidebar.tsx
index 0c04a8590df..789162f49e2 100644
--- a/src/annotator/sidebar.tsx
+++ b/src/annotator/sidebar.tsx
@@ -1,8 +1,8 @@
+import type { ToastMessage } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import * as Hammer from 'hammerjs';
import { render } from 'preact';
-import type { ToastMessage } from '../shared/components/ToastMessages';
import { addConfigFragment } from '../shared/config-fragment';
import { sendErrorsTo } from '../shared/frame-error-capture';
import { ListenerCollection } from '../shared/listener-collection';
diff --git a/src/shared/components/ToastMessages.tsx b/src/shared/components/ToastMessages.tsx
deleted file mode 100644
index a512e061ed4..00000000000
--- a/src/shared/components/ToastMessages.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-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 (
- onDismiss(message.id)}
- variant="raised"
- >
- {prefix}
- {message.message}
-
- );
-}
-
-type BaseToastMessageTransitionType = FunctionComponent<
- ComponentProps & {
- transitionClasses?: ToastMessageTransitionClasses;
- }
->;
-
-const BaseToastMessageTransition: BaseToastMessageTransitionType = ({
- direction,
- onTransitionEnd,
- children,
- transitionClasses = {},
-}) => {
- const isDismissed = direction === 'out';
- const containerRef = useRef(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 (
-
- {children}
-
- );
-};
-
-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([]);
- const scheduledMessages = useRef(new Set());
-
- 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 (
-
- {messages.map(message => {
- const isDismissed = dismissedMessages.includes(message.id);
- return (
- -
- onTransitionEnd(direction, message)}
- transitionClasses={transitionClasses}
- >
-
-
-
- );
- })}
-
- );
-}
diff --git a/src/shared/components/test/ToastMessages-test.js b/src/shared/components/test/ToastMessages-test.js
deleted file mode 100644
index a8cb3aec73d..00000000000
--- a/src/shared/components/test/ToastMessages-test.js
+++ /dev/null
@@ -1,159 +0,0 @@
-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(
- ,
- { 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);
- });
-});
diff --git a/src/sidebar/components/ToastMessages.tsx b/src/sidebar/components/ToastMessages.tsx
index dd04584b0ca..2e331ef77af 100644
--- a/src/sidebar/components/ToastMessages.tsx
+++ b/src/sidebar/components/ToastMessages.tsx
@@ -1,6 +1,6 @@
+import { ToastMessages as BaseToastMessages } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
-import BaseToastMessages from '../../shared/components/ToastMessages';
import { withServices } from '../service-context';
import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store';
diff --git a/src/sidebar/services/frame-sync.ts b/src/sidebar/services/frame-sync.ts
index 78d63ea34e9..48f7617cc4c 100644
--- a/src/sidebar/services/frame-sync.ts
+++ b/src/sidebar/services/frame-sync.ts
@@ -1,8 +1,8 @@
+import type { ToastMessage } from '@hypothesis/frontend-shared';
import debounce from 'lodash.debounce';
import type { DebouncedFunction } from 'lodash.debounce';
import shallowEqual from 'shallowequal';
-import type { ToastMessage } from '../../shared/components/ToastMessages';
import { ListenerCollection } from '../../shared/listener-collection';
import {
PortFinder,
diff --git a/src/sidebar/services/toast-messenger.ts b/src/sidebar/services/toast-messenger.ts
index 3f144d948bb..5b4b3a4a7f1 100644
--- a/src/sidebar/services/toast-messenger.ts
+++ b/src/sidebar/services/toast-messenger.ts
@@ -1,6 +1,6 @@
+import type { ToastMessage } from '@hypothesis/frontend-shared';
import { TinyEmitter } from 'tiny-emitter';
-import type { ToastMessage } from '../../shared/components/ToastMessages';
import { generateHexString } from '../../shared/random';
import type { SidebarStore } from '../store';
diff --git a/src/sidebar/store/modules/toast-messages.ts b/src/sidebar/store/modules/toast-messages.ts
index cbf03f10224..cbd9dc234b3 100644
--- a/src/sidebar/store/modules/toast-messages.ts
+++ b/src/sidebar/store/modules/toast-messages.ts
@@ -1,4 +1,5 @@
-import type { ToastMessage } from '../../../shared/components/ToastMessages';
+import type { ToastMessage } from '@hypothesis/frontend-shared';
+
import { createStoreModule, makeAction } from '../create-store';
/**