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'; /**