Commit c24514cb authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Extract reusable BaseToastMessages component

parent ac71acc0
import {
Card,
Link,
CancelIcon,
CautionIcon,
CheckIcon,
} from '@hypothesis/frontend-shared/lib/next';
import classnames from 'classnames';
import type { ToastMessage } from '../../sidebar/store/modules/toast-messages';
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.
*
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
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)}: `
: '';
let Icon;
switch (message.type) {
case 'success':
Icon = CheckIcon;
break;
case 'error':
Icon = CancelIcon;
break;
case 'notice':
default:
Icon = CautionIcon;
break;
}
/**
* a11y linting is disabled here: There is a click-to-remove handler on a
* non-interactive element. This allows sighted users to get the toast message
* out of their way if it interferes with interacting with the underlying
* components. This shouldn't pose the same irritation to users with screen-
* readers as the rendered toast messages shouldn't impede interacting with
* the underlying document.
*/
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<Card
classes={classnames('flex', {
'sr-only': message.visuallyHidden,
'border-red-error': message.type === 'error',
'border-yellow-notice': message.type === 'notice',
'border-green-success': message.type === 'success',
})}
onClick={() => onDismiss(message.id)}
>
<div
className={classnames('flex items-center p-3 text-white', {
'bg-red-error': message.type === 'error',
'bg-yellow-notice': message.type === 'notice',
'bg-green-success': message.type === 'success',
})}
>
<Icon
className={classnames(
// Adjust alignment of icon to appear more aligned with text
'mt-[2px]'
)}
/>
</div>
<div className="grow p-3" data-testid="toast-message-text">
<strong>{prefix}</strong>
{message.message}
{message.moreInfoURL && (
<div className="text-right">
<Link
href={message.moreInfoURL}
onClick={
event =>
event.stopPropagation() /* consume the event so that it does not dismiss the message */
}
target="_new"
>
More info
</Link>
</div>
)}
</div>
</Card>
);
}
export type ToastMessageProps = {
messages: ToastMessage[];
onMessageDismiss: (messageId: string) => void;
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
export default function BaseToastMessages({
messages,
onMessageDismiss,
}: ToastMessageProps) {
// The `ul` containing any toast messages is absolute-positioned and the full
// width of the viewport. Each toast message `li` has its position and width
// constrained by `container` configuration in tailwind.
return (
<div>
<ul
aria-live="polite"
aria-relevant="additions"
className="absolute z-2 left-0 w-full"
>
{messages.map(message => (
<li
className={classnames(
'relative w-full container hover:cursor-pointer',
{
// 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,
// Slide in from right in narrow viewports; fade in larger
// viewports to toast message isn't flying too far
'motion-safe:animate-slide-in-from-right lg:animate-fade-in':
!message.isDismissed,
// Only ever fade in if motion-reduction is preferred
'motion-reduce:animate-fade-in': !message.isDismissed,
'animate-fade-out': message.isDismissed,
}
)}
key={message.id}
>
<ToastMessageItem message={message} onDismiss={onMessageDismiss} />
</li>
))}
</ul>
</div>
);
}
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../test-util/accessibility';
import BaseToastMessages from '../BaseToastMessages';
describe('BaseToastMessages', () => {
let fakeOnMessageDismiss;
let fakeErrorMessage = () => {
return {
type: 'error',
message: 'boo',
id: 'someid2',
isDismissed: false,
};
};
let fakeSuccessMessage = () => {
return {
type: 'success',
message: 'yay',
id: 'someid',
isDismissed: false,
};
};
let fakeNoticeMessage = () => {
return {
type: 'notice',
message: 'you should know...',
id: 'someid3',
isDismissed: false,
moreInfoURL: 'http://www.example.com',
};
};
function createComponent(messages = []) {
return mount(
<BaseToastMessages
messages={messages}
onMessageDismiss={fakeOnMessageDismiss}
/>
);
}
beforeEach(() => {
fakeOnMessageDismiss = sinon.stub();
});
it('should render a `ToastMessageItem` for each provided message', () => {
const wrapper = createComponent([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
]);
assert.lengthOf(wrapper.find('ToastMessageItem'), 3);
});
describe('`ToastMessageItem` sub-component', () => {
it('should dismiss the message when clicked', () => {
const wrapper = createComponent([fakeSuccessMessage()]);
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
act(() => {
messageContainer.dispatchEvent(new Event('click'));
});
assert.calledOnce(fakeOnMessageDismiss);
});
it('should set a screen-reader-only class on `visuallyHidden` messages', () => {
const message = fakeSuccessMessage();
message.visuallyHidden = true;
const wrapper = createComponent([message]);
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
assert.include(messageContainer.className, 'sr-only');
});
it('should not dismiss the message if a "More info" link is clicked', () => {
const wrapper = createComponent([fakeNoticeMessage()]);
const link = wrapper.find('Link');
act(() => {
link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true }));
});
assert.notCalled(fakeOnMessageDismiss);
});
[
{ message: fakeSuccessMessage(), prefix: 'Success: ' },
{ message: fakeErrorMessage(), prefix: 'Error: ' },
{ message: fakeNoticeMessage(), prefix: '' },
].forEach(testCase => {
it('should prefix the message with the message type', () => {
const wrapper = createComponent([testCase.message]);
assert.include(
wrapper.text(),
`${testCase.prefix}${testCase.message.message}`
);
});
});
[
{ messages: [fakeSuccessMessage()], icons: ['CheckIcon'] },
{ messages: [fakeErrorMessage()], icons: ['CancelIcon'] },
{ messages: [fakeNoticeMessage()], icons: ['CautionIcon'] },
{
messages: [fakeSuccessMessage(), fakeErrorMessage()],
icons: ['CheckIcon', 'CancelIcon'],
},
].forEach(testCase => {
it('should render an appropriate icon for the message type', () => {
const wrapper = createComponent(testCase.messages);
testCase.icons.forEach(iconName => {
assert.isTrue(wrapper.find(iconName).exists());
});
});
});
});
it('should render a "more info" link if URL is present in message object', () => {
const wrapper = createComponent([fakeNoticeMessage()]);
const link = wrapper.find('Link');
assert.equal(link.props().href, 'http://www.example.com');
assert.equal(link.text(), 'More info');
});
describe('a11y', () => {
it(
'should pass a11y checks',
checkAccessibility([
{
content: () =>
createComponent([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
]),
},
])
);
});
});
import {
Card,
Link,
CancelIcon,
CautionIcon,
CheckIcon,
} from '@hypothesis/frontend-shared/lib/next';
import classnames from 'classnames';
import BaseToastMessages from '../../shared/components/BaseToastMessages';
import { withServices } from '../service-context';
import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store';
import type { ToastMessage } from '../store/modules/toast-messages';
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.
*
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
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)}: `
: '';
let Icon;
switch (message.type) {
case 'success':
Icon = CheckIcon;
break;
case 'error':
Icon = CancelIcon;
break;
case 'notice':
default:
Icon = CautionIcon;
break;
}
/**
* a11y linting is disabled here: There is a click-to-remove handler on a
* non-interactive element. This allows sighted users to get the toast message
* out of their way if it interferes with interacting with the underlying
* components. This shouldn't pose the same irritation to users with screen-
* readers as the rendered toast messages shouldn't impede interacting with
* the underlying document.
*/
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<Card
classes={classnames('flex', {
'sr-only': message.visuallyHidden,
'border-red-error': message.type === 'error',
'border-yellow-notice': message.type === 'notice',
'border-green-success': message.type === 'success',
})}
onClick={() => onDismiss(message.id)}
>
<div
className={classnames('flex items-center p-3 text-white', {
'bg-red-error': message.type === 'error',
'bg-yellow-notice': message.type === 'notice',
'bg-green-success': message.type === 'success',
})}
>
<Icon
className={classnames(
// Adjust alignment of icon to appear more aligned with text
'mt-[2px]'
)}
/>
</div>
<div className="grow p-3" data-testid="toast-message-text">
<strong>{prefix}</strong>
{message.message}
{message.moreInfoURL && (
<div className="text-right">
<Link
href={message.moreInfoURL}
onClick={
event =>
event.stopPropagation() /* consume the event so that it does not dismiss the message */
}
target="_new"
>
More info
</Link>
</div>
)}
</div>
</Card>
);
}
export type ToastMessageProps = {
// injected
toastMessenger: ToastMessengerService;
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
function ToastMessages({ toastMessenger }: ToastMessageProps) {
const store = useSidebarStore();
const messages = store.getToastMessages();
// The `ul` containing any toast messages is absolute-positioned and the full
// width of the viewport. Each toast message `li` has its position and width
// constrained by `container` configuration in tailwind.
return (
<div>
<ul
aria-live="polite"
aria-relevant="additions"
className="absolute z-2 left-0 w-full"
>
{messages.map(message => (
<li
className={classnames(
'relative w-full container hover:cursor-pointer',
{
// 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,
// Slide in from right in narrow viewports; fade in in
// larger viewports to toast message isn't flying too far
'motion-safe:animate-slide-in-from-right lg:animate-fade-in':
!message.isDismissed,
// Only ever fade in if motion-reduction is preferred
'motion-reduce:animate-fade-in': !message.isDismissed,
'animate-fade-out': message.isDismissed,
}
)}
key={message.id}
>
<ToastMessageItem
message={message}
onDismiss={(id: string) => toastMessenger.dismiss(id)}
/>
</li>
))}
</ul>
</div>
<BaseToastMessages
messages={messages}
onMessageDismiss={(id: string) => toastMessenger.dismiss(id)}
/>
);
}
......
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../test-util/accessibility';
import { mockImportedComponents } from '../../../test-util/mock-imported-components';
import ToastMessages, { $imports } from '../ToastMessages';
......@@ -9,29 +7,11 @@ describe('ToastMessages', () => {
let fakeStore;
let fakeToastMessenger;
let fakeErrorMessage = () => {
return {
type: 'error',
message: 'boo',
id: 'someid2',
isDismissed: false,
};
};
let fakeSuccessMessage = () => {
return {
type: 'success',
message: 'yay',
id: 'someid',
isDismissed: false,
};
};
let fakeNoticeMessage = () => {
const fakeMessage = () => {
return {
type: 'notice',
message: 'you should know...',
id: 'someid3',
id: 'someId',
isDismissed: false,
moreInfoURL: 'http://www.example.com',
};
......@@ -62,122 +42,26 @@ describe('ToastMessages', () => {
$imports.$restore();
});
it('should render a `ToastMessageItem` for each message returned by the store', () => {
it('should render all messages returned by the store', () => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
fakeMessage(),
fakeMessage(),
fakeMessage(),
]);
const wrapper = createComponent();
assert.lengthOf(wrapper.find('ToastMessageItem'), 3);
assert.lengthOf(wrapper.find('BaseToastMessages').prop('messages'), 3);
});
describe('`ToastMessageItem` sub-component', () => {
it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeSuccessMessage()]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
act(() => {
messageContainer.dispatchEvent(new Event('click'));
});
assert.calledOnce(fakeToastMessenger.dismiss);
});
it('should set a screen-reader-only class on `visuallyHidden` messages', () => {
const message = fakeSuccessMessage();
message.visuallyHidden = true;
fakeStore.getToastMessages.returns([message]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessageItem').getDOMNode();
assert.include(messageContainer.className, 'sr-only');
});
it('should not dismiss the message if a "More info" link is clicked', () => {
fakeStore.getToastMessages.returns([fakeNoticeMessage()]);
const wrapper = createComponent();
const link = wrapper.find('Link');
act(() => {
link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true }));
});
assert.notCalled(fakeToastMessenger.dismiss);
});
[
{ message: fakeSuccessMessage(), prefix: 'Success: ' },
{ message: fakeErrorMessage(), prefix: 'Error: ' },
{ message: fakeNoticeMessage(), prefix: '' },
].forEach(testCase => {
it('should prefix the message with the message type', () => {
fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent();
assert.include(
wrapper.text(),
`${testCase.prefix}${testCase.message.message}`
);
});
});
[
{ messages: [fakeSuccessMessage()], icons: ['CheckIcon'] },
{ messages: [fakeErrorMessage()], icons: ['CancelIcon'] },
{ messages: [fakeNoticeMessage()], icons: ['CautionIcon'] },
{
messages: [fakeSuccessMessage(), fakeErrorMessage()],
icons: ['CheckIcon', 'CancelIcon'],
},
].forEach(testCase => {
it('should render an appropriate icon for the message type', () => {
fakeStore.getToastMessages.returns(testCase.messages);
const wrapper = createComponent();
testCase.icons.forEach(iconName => {
assert.isTrue(wrapper.find(iconName).exists());
});
});
});
});
it('should render a "more info" link if URL is present in message object', () => {
fakeStore.getToastMessages.returns([fakeNoticeMessage()]);
it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeMessage()]);
const wrapper = createComponent();
const messageContainer = wrapper.find('BaseToastMessages');
const link = wrapper.find('Link');
assert.equal(link.props().href, 'http://www.example.com');
assert.equal(link.text(), 'More info');
});
describe('a11y', () => {
beforeEach(() => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
fakeNoticeMessage(),
]);
});
messageContainer.prop('onMessageDismiss')();
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createComponent(),
},
])
);
assert.calledOnce(fakeToastMessenger.dismiss);
});
});
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