Skip to content

Commit

Permalink
Use new ToastMessages component with internal dismissing handling
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Sep 25, 2023
1 parent 240616a commit 2fa444e
Show file tree
Hide file tree
Showing 14 changed files with 407 additions and 447 deletions.
4 changes: 2 additions & 2 deletions src/annotator/components/ToastMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'preact/hooks';

import BaseToastMessages from '../../shared/components/BaseToastMessages';
import type { ToastMessage } from '../../shared/components/BaseToastMessages';
import BaseToastMessages from '../../shared/components/ToastMessages';
import type { ToastMessage } from '../../shared/components/ToastMessages';
import type { Emitter } from '../util/emitter';

export type ToastMessagesProps = {
Expand Down
9 changes: 6 additions & 3 deletions src/annotator/components/test/ToastMessages-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ describe('ToastMessages', () => {

const createComponent = () => mount(<ToastMessages emitter={emitter} />);

const toastMessagesList = wrapper =>
wrapper.find('[messages]').prop('messages');

beforeEach(() => {
emitter = new Emitter(new EventEmitter());
});
Expand All @@ -25,14 +28,14 @@ describe('ToastMessages', () => {
const wrapper = createComponent();

// Initially messages is empty
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 0);
assert.lengthOf(toastMessagesList(wrapper), 0);

emitter.publish('toastMessageAdded', fakeMessage('someId1'));
emitter.publish('toastMessageAdded', fakeMessage('someId2'));
emitter.publish('toastMessageAdded', fakeMessage('someId3'));
wrapper.update();

assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 3);
assert.lengthOf(toastMessagesList(wrapper), 3);
});

it('removes toast existing messages on toastMessageDismissed', () => {
Expand All @@ -49,6 +52,6 @@ describe('ToastMessages', () => {
emitter.publish('toastMessageDismissed', 'someId4');
wrapper.update();

assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 2);
assert.lengthOf(toastMessagesList(wrapper), 2);
});
});
2 changes: 1 addition & 1 deletion src/annotator/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import classnames from 'classnames';
import * as Hammer from 'hammerjs';
import { render } from 'preact';

import type { ToastMessage } from '../shared/components/BaseToastMessages';
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';
Expand Down
122 changes: 0 additions & 122 deletions src/shared/components/BaseToastMessages.tsx

This file was deleted.

205 changes: 205 additions & 0 deletions src/shared/components/ToastMessages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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>
);
}
Loading

0 comments on commit 2fa444e

Please sign in to comment.