Commit 5aa494bf authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Re-implement toast/flash messaging

* preact-compatible
* more accessible
parent 5c466405
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" class="Icon Icon--check"><g fill-rule="evenodd"><rect fill="none" stroke="none" x="0" y="0" width="16" height="16"></rect><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 3L6 13 3 8"></path></g></svg>
......@@ -19,6 +19,7 @@ const icons = {
edit: require('../../images/icons/edit.svg'),
email: require('../../images/icons/email.svg'),
'expand-menu': require('../../images/icons/expand-menu.svg'),
error: require('../../images/icons/cancel.svg'),
external: require('../../images/icons/external.svg'),
facebook: require('../../images/icons/facebook.svg'),
flag: require('../../images/icons/flag.svg'),
......@@ -45,6 +46,7 @@ const icons = {
reply: require('../../images/icons/reply.svg'),
search: require('../../images/icons/search.svg'),
share: require('../../images/icons/share.svg'),
success: require('../../images/icons/check.svg'),
sort: require('../../images/icons/sort.svg'),
trash: require('../../images/icons/trash.svg'),
twitter: require('../../images/icons/twitter.svg'),
......
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { act } from 'preact/test-utils';
import mockImportedComponents from '../../../test-util/mock-imported-components';
import ToastMessages, { $imports } from '../toast-messages';
import { checkAccessibility } from '../../../test-util/accessibility';
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,
};
};
function createComponent(props) {
return mount(
<ToastMessages toastMessenger={fakeToastMessenger} {...props} />
);
}
beforeEach(() => {
fakeStore = {
getToastMessages: sinon.stub(),
};
fakeToastMessenger = {
dismiss: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
afterEach(() => {
$imports.$restore();
});
it('should render a `ToastMessage` for each message returned by the store', () => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
]);
const wrapper = createComponent();
assert.lengthOf(wrapper.find('ToastMessage'), 2);
});
describe('`ToastMessage` sub-component', () => {
it('should add `is-dismissed` stateful class name if message has been dismissed', () => {
const message = fakeSuccessMessage();
message.isDismissed = true;
fakeStore.getToastMessages.returns([message]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessage li');
assert.isTrue(messageContainer.hasClass('is-dismissed'));
});
it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeSuccessMessage()]);
const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessage li');
act(() => {
messageContainer.simulate('click');
});
assert.calledOnce(fakeToastMessenger.dismiss);
});
[
{ message: fakeSuccessMessage(), className: 'toast-message--success' },
{ message: fakeErrorMessage(), className: 'toast-message--error' },
].forEach(testCase => {
it('should assign a CSS class based on message type', () => {
fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent();
const messageWrapper = wrapper.find('.toast-message');
assert.isTrue(messageWrapper.hasClass(testCase.className));
});
[
{ message: fakeSuccessMessage(), prefix: 'Success' },
{ message: fakeErrorMessage(), prefix: 'Error' },
].forEach(testCase => {
it('should prefix the message with the message type', () => {
fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent();
const messageContent = wrapper
.find('.toast-message__message')
.first();
assert.equal(
messageContent.text(),
`${testCase.prefix}: ${testCase.message.message}`
);
});
});
});
[
{ messages: [fakeSuccessMessage()], icons: ['success'] },
{ messages: [fakeErrorMessage()], icons: ['error'] },
{
messages: [fakeSuccessMessage(), fakeErrorMessage()],
icons: ['success', 'error'],
},
].forEach(testCase => {
it('should render an appropriate icon for the message type', () => {
fakeStore.getToastMessages.returns(testCase.messages);
const wrapper = createComponent();
const iconProps = wrapper
.find('SvgIcon')
.map(iconWrapper => iconWrapper.props().name);
assert.deepEqual(iconProps, testCase.icons);
});
});
});
describe('a11y', () => {
beforeEach(() => {
fakeStore.getToastMessages.returns([
fakeSuccessMessage(),
fakeErrorMessage(),
]);
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createComponent(),
},
])
);
});
});
import classnames from 'classnames';
import { createElement } from 'preact';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { withServices } from '../util/service-context';
import SvgIcon from './svg-icon';
/**
* An individual toast message—a brief and transient success or error message.
* The message may be dismissed by clicking on it.
* Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time.
*/
function ToastMessage({ message, onDismiss }) {
// Capitalize the message type for prepending
const prefix = message.type.charAt(0).toUpperCase() + message.type.slice(1);
/**
* 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 */
<li
className={classnames('toast-message-container', {
'is-dismissed': message.isDismissed,
})}
onClick={() => onDismiss(message.id)}
>
<div
className={classnames(
'toast-message',
`toast-message--${message.type}`
)}
>
<div className="toast-message__type">
<SvgIcon name={message.type} className="toast-message__icon" />
</div>
<div className="toast-message__message">
<strong>{prefix}: </strong>
{message.message}
</div>
</div>
</li>
);
}
ToastMessage.propTypes = {
// The message object to render
message: propTypes.object.isRequired,
onDismiss: propTypes.func,
};
/**
* A collection of toast messages. These are rendered within an `aria-live`
* region for accessibility with screen readers.
*/
function ToastMessages({ toastMessenger }) {
const messages = useStore(store => store.getToastMessages());
return (
<div>
<ul
aria-live="polite"
aria-relevant="additions"
className="toast-messages"
>
{messages.map(message => (
<ToastMessage
message={message}
key={message.id}
onDismiss={toastMessenger.dismiss}
/>
))}
</ul>
</div>
);
}
ToastMessages.propTypes = {
// Injected services
toastMessenger: propTypes.object.isRequired,
};
ToastMessages.injectedProps = ['toastMessenger'];
export default withServices(ToastMessages);
......@@ -152,6 +152,7 @@ import SidebarContentError from './components/sidebar-content-error';
import SvgIcon from './components/svg-icon';
import TagEditor from './components/tag-editor';
import TagList from './components/tag-list';
import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular.
......@@ -189,6 +190,7 @@ import sessionService from './services/session';
import streamFilterService from './services/stream-filter';
import streamerService from './services/streamer';
import tagsService from './services/tags';
import toastMessenger from './services/toast-messenger';
import unicodeService from './services/unicode';
import viewFilterService from './services/view-filter';
......@@ -232,6 +234,7 @@ function startAngularApp(config) {
.register('streamer', streamerService)
.register('streamFilter', streamFilterService)
.register('tags', tagsService)
.register('toastMessenger', toastMessenger)
.register('unicode', unicodeService)
.register('viewFilter', viewFilterService)
.register('store', store);
......@@ -292,6 +295,7 @@ function startAngularApp(config) {
.component('tagEditor', wrapComponent(TagEditor))
.component('tagList', wrapComponent(TagList))
.component('threadList', threadList)
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
// Register services, the store and utilities with Angular, so that
......@@ -318,6 +322,7 @@ function startAngularApp(config) {
.service('session', () => container.get('session'))
.service('streamer', () => container.get('streamer'))
.service('streamFilter', () => container.get('streamFilter'))
.service('toastMessenger', () => container.get('toastMessenger'))
// Redux store
.service('store', () => container.get('store'))
......
import toastMessenger from '../toast-messenger';
describe('toastMessenger', function() {
let clock;
let fakeStore;
let service;
beforeEach(() => {
fakeStore = {
addToastMessage: sinon.stub(),
getToastMessage: sinon.stub(),
hasToastMessage: sinon.stub(),
removeToastMessage: sinon.stub(),
updateToastMessage: sinon.stub(),
};
clock = sinon.useFakeTimers();
service = toastMessenger(fakeStore);
});
afterEach(() => {
clock.restore();
});
describe('#success', () => {
it('does not add a new success message if a matching one already exists in the store', () => {
fakeStore.hasToastMessage.returns(true);
service.success('This is my message');
assert.calledWith(
fakeStore.hasToastMessage,
'success',
'This is my message'
);
assert.notCalled(fakeStore.addToastMessage);
});
it('adds a new success toast message to the store', () => {
fakeStore.hasToastMessage.returns(false);
service.success('hooray');
assert.calledWith(
fakeStore.addToastMessage,
sinon.match({ type: 'success', message: 'hooray' })
);
});
it('dismisses the message after timeout fires', () => {
fakeStore.hasToastMessage.returns(false);
fakeStore.getToastMessage.returns(undefined);
service.success('hooray');
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock.next();
assert.calledOnce(fakeStore.getToastMessage);
assert.notCalled(fakeStore.updateToastMessage);
});
});
describe('#error', () => {
it('does not add a new error message if one with the same message text already exists', () => {
fakeStore.hasToastMessage.returns(true);
service.error('This is my message');
assert.calledWith(
fakeStore.hasToastMessage,
'error',
'This is my message'
);
assert.notCalled(fakeStore.addToastMessage);
});
it('adds a new error toast message to the store', () => {
fakeStore.hasToastMessage.returns(false);
service.error('boo');
assert.calledWith(
fakeStore.addToastMessage,
sinon.match({ type: 'error', message: 'boo' })
);
});
it('dismisses the message after timeout fires', () => {
fakeStore.hasToastMessage.returns(false);
fakeStore.getToastMessage.returns(undefined);
service.error('boo');
// Move to the first scheduled timeout, which should invoke the
// `dismiss` method
clock.next();
assert.calledOnce(fakeStore.getToastMessage);
assert.notCalled(fakeStore.updateToastMessage);
});
});
describe('#dismiss', () => {
it('does not dismiss the message if it does not exist', () => {
fakeStore.getToastMessage.returns(undefined);
service.dismiss('someid');
assert.notCalled(fakeStore.updateToastMessage);
});
it('does not dismiss a message if it is already dismissed', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: true,
});
service.dismiss('someid');
assert.notCalled(fakeStore.updateToastMessage);
});
it('updates the message object to set `isDimissed` to `true`', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: false,
});
service.dismiss('someid');
assert.calledWith(
fakeStore.updateToastMessage,
sinon.match({ isDismissed: true })
);
});
it('removes the message from the store after timeout fires', () => {
fakeStore.getToastMessage.returns({
type: 'success',
message: 'yay',
isDismissed: false,
});
service.dismiss('someid');
// Advance the clock to fire the timeout that will remove the message
clock.next();
assert.calledOnce(fakeStore.removeToastMessage);
assert.calledWith(fakeStore.removeToastMessage, 'someid');
});
});
});
/**
* A service for managing toast messages. The service will auto-dismiss and
* remove toast messages created with `#success()` or `#error()`. Added
* messages may be manually dismissed with the `#dismiss()` method.
*/
import { generateHexString } from '../util/random';
// How long toast messages should be displayed before they are dismissed, in ms
const MESSAGE_DISPLAY_TIME = 5000;
// Delay before removing the message entirely (allows animations to complete)
const MESSAGE_DISMISS_DELAY = 500;
// @ngInject
export default function toastMessenger(store) {
/**
* Update a toast message's dismiss status and set a timeout to remove
* it after a bit. This allows effects/animations to happen before the
* message is removed entirely.
*
* @param {string} messageId - The value of the `id` property for the
* message that is to be dismissed.
*/
const dismiss = messageId => {
const message = store.getToastMessage(messageId);
if (message && !message.isDismissed) {
store.updateToastMessage({ ...message, isDismissed: true });
setTimeout(() => {
store.removeToastMessage(messageId);
}, MESSAGE_DISMISS_DELAY);
}
};
/**
* Add a new toast message to the store and set a timeout to dismiss it
* after some time. This method will not add a message that is already
* extant in the store's collection of toast messages (i.e. has the same
* `type` and `message` text of an existing message).
*
* @param {('error'|'success')} type
* @param {string} message - The message to be rendered
*/
const addMessage = (type, message) => {
// Do not add duplicate messages (messages with the same type and text)
if (store.hasToastMessage(type, message)) {
return;
}
const id = generateHexString(10);
store.addToastMessage({ type, message, id, isDismissed: false });
// Attempt to dismiss message after a set time period. NB: The message may
// have been removed by other mechanisms at this point; do not assume its
// presence.
setTimeout(() => {
dismiss(id);
}, MESSAGE_DISPLAY_TIME);
};
/**
* Add an error toast message with `messageText`
*/
const error = messageText => {
addMessage('error', messageText);
};
/**
* Add a success toast message with `messageText`
*/
const success = messageText => {
addMessage('success', messageText);
};
return {
dismiss,
error,
success,
};
}
......@@ -43,6 +43,7 @@ import realTimeUpdates from './modules/real-time-updates';
import selection from './modules/selection';
import session from './modules/session';
import sidebarPanels from './modules/sidebar-panels';
import toastMessages from './modules/toast-messages';
import viewer from './modules/viewer';
/**
......@@ -98,6 +99,7 @@ export default function store($rootScope, settings) {
selection,
session,
sidebarPanels,
toastMessages,
viewer,
];
return createStore(modules, [settings], middleware);
......
import createStore from '../../create-store';
import toastMessages from '../toast-messages';
describe('store/modules/toast-messages', function() {
let store;
let fakeToastMessage;
beforeEach(() => {
store = createStore([toastMessages]);
fakeToastMessage = {
id: 'myToast',
type: 'anyType',
message: 'This is a message',
};
});
describe('actions', () => {
describe('addToastMessage', () => {
it('adds the provided object to the array of messages in state', () => {
store.addToastMessage(fakeToastMessage);
const messages = store.getToastMessages();
assert.lengthOf(messages, 1);
// These two objects should not have the same reference
assert.notEqual(messages[0], fakeToastMessage);
assert.equal(messages[0].id, 'myToast');
assert.equal(messages[0].type, 'anyType');
assert.equal(messages[0].message, 'This is a message');
});
it('adds duplicate messages to the array of messages in state', () => {
// This store module doesn't care about duplicates
store.addToastMessage(fakeToastMessage);
store.addToastMessage(fakeToastMessage);
const messages = store.getToastMessages();
assert.lengthOf(messages, 2);
assert.notEqual(messages[0], messages[1]);
});
});
describe('removeToastMessage', () => {
it('removes messages that match the provided id', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.id = 'myToast2';
store.addToastMessage(fakeToastMessage);
store.removeToastMessage('myToast2');
const messages = store.getToastMessages();
assert.lengthOf(messages, 1);
assert.equal(messages[0].id, 'myToast');
});
it('should not remove any objects if none match the provided id', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.id = 'myToast2';
store.addToastMessage(fakeToastMessage);
store.removeToastMessage('myToast3');
assert.lengthOf(store.getToastMessages(), 2);
});
});
describe('updateToastMessage', () => {
it('should update the message object', () => {
const updatedMessage = {
id: 'myToast',
type: 'whatever',
message: 'updated',
};
store.addToastMessage(fakeToastMessage);
store.updateToastMessage(updatedMessage);
assert.deepEqual(store.getToastMessage('myToast'), updatedMessage);
});
it('should be OK if there is no matching message object', () => {
store.addToastMessage(fakeToastMessage);
store.updateToastMessage({ id: 'random' });
assert.lengthOf(store.getToastMessages(), 1);
});
});
});
describe('selectors', () => {
describe('getToastMessage', () => {
it('should return message with matching `id`', () => {
const anotherMessage = { ...fakeToastMessage, id: 'thisOne' };
store.addToastMessage(fakeToastMessage);
store.addToastMessage(anotherMessage);
const retrievedMessage = store.getToastMessage('thisOne');
assert.deepEqual(retrievedMessage, anotherMessage);
});
it('should return `undefined` if no message matches', () => {
store.addToastMessage(fakeToastMessage);
assert.isUndefined(store.getToastMessage('someRandomId'));
});
});
describe('getToastMessages', () => {
it('should return its collection of messages', () => {
assert.isArray(store.getToastMessages());
});
});
describe('hasToastMessage', () => {
it('should return `true` if one message matches `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
fakeToastMessage.type = 'anotherType';
store.addToastMessage(fakeToastMessage);
assert.isTrue(store.hasToastMessage('anyType', 'This is a message'));
});
it('should return `true` if more than one message matches `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
store.addToastMessage(fakeToastMessage);
assert.isTrue(store.hasToastMessage('anyType', 'This is a message'));
});
it('should return `false` if no messages match both `type` and `state`', () => {
store.addToastMessage(fakeToastMessage);
assert.isFalse(
store.hasToastMessage('anotherType', 'This is a message')
);
assert.isFalse(
store.hasToastMessage('anyType', 'This is another message')
);
});
});
});
});
import * as util from '../util';
/**
* A store module for managing a collection of toast messages. This module
* maintains state only; it's up to other layers to handle the management
* and interactions with these messages.
*/
function init() {
return {
messages: [],
};
}
const update = {
ADD_MESSAGE: function(state, action) {
return {
messages: state.messages.concat({ ...action.message }),
};
},
REMOVE_MESSAGE: function(state, action) {
const updatedMessages = state.messages.filter(
message => message.id !== action.id
);
return { messages: updatedMessages };
},
UPDATE_MESSAGE: function(state, action) {
const updatedMessages = state.messages.map(message => {
if (message.id && message.id === action.message.id) {
return { ...action.message };
}
return message;
});
return { messages: updatedMessages };
},
};
const actions = util.actionTypes(update);
/** Actions */
function addMessage(message) {
return { type: actions.ADD_MESSAGE, message };
}
/**
* Remove the `message` with the corresponding `id` property value.
*
* @param {string} id
*/
function removeMessage(id) {
return { type: actions.REMOVE_MESSAGE, id };
}
/**
* Update the `message` object (lookup is by `id`).
*
* @param {Object} message
*/
function updateMessage(message) {
return { type: actions.UPDATE_MESSAGE, message };
}
/** Selectors */
/**
* Retrieve a message by `id`
*
* @param {string} id
* @return {Object|undefined}
*/
function getMessage(state, id) {
return state.toastMessages.messages.find(message => message.id === id);
}
/**
* Retrieve all current messages
*
* @return {Object[]}
*/
function getMessages(state) {
return state.toastMessages.messages;
}
/**
* Return boolean indicating whether a message with the same type and message
* text exists in the state's collection of messages. This matches messages
* by content, not by ID (true uniqueness).
*
* @param {string} type
* @param {string} text
* @return {boolean}
*/
function hasMessage(state, type, text) {
return state.toastMessages.messages.some(message => {
return message.type === type && message.message === text;
});
}
export default {
init,
namespace: 'toastMessages',
update,
actions: {
addToastMessage: addMessage,
removeToastMessage: removeMessage,
updateToastMessage: updateMessage,
},
selectors: {
getToastMessage: getMessage,
getToastMessages: getMessages,
hasToastMessage: hasMessage,
},
};
......@@ -8,6 +8,7 @@
</top-bar>
<div class="content">
<toast-messages></toast-messages>
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-view=""></main>
......
@use "../../mixins/panel";
@use "../../variables" as var;
.toast-messages {
position: absolute;
z-index: 1;
width: 100%;
left: 0;
padding: 0 8px;
}
.toast-message-container {
position: relative;
width: 100%;
animation: slidein 0.3s forwards ease-in-out;
&:hover {
cursor: pointer;
}
&.is-dismissed {
animation: fadeout 0.3s forwards;
}
}
.toast-message {
@include panel.panel;
display: flex;
position: relative;
width: 100%;
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15);
border-radius: 2px;
margin-bottom: 0.75em;
&--success {
border: 1px solid var.$color-success;
}
&--error {
border: 1px solid var.$color-error;
}
&__type {
padding: 1em;
color: white;
}
&--success &__type {
background-color: var.$color-success;
}
&--error &__type {
background-color: var.$color-error;
}
&__icon {
// Specific adjustments for success and error icons
margin-top: 2px;
}
&__message {
padding: 1em;
}
}
@keyframes slidein {
0% {
opacity: 0;
left: 100%;
}
80% {
left: -10px;
}
100% {
left: 0;
opacity: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
......@@ -63,6 +63,7 @@
@use './components/tag-editor';
@use './components/tag-list';
@use './components/thread-list';
@use './components/toast-messages';
@use './components/tooltip';
@use './components/top-bar';
@use './components/tutorial';
......
......@@ -38,6 +38,9 @@ $black: #000 !default;
// Colors
$color-success: #00a36d;
$color-warning: #fbc168;
$color-error: #d93c3f;
$brand: #bd1c2b;
$highlight: #58cef4;
......
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