Commit 0e1bd045 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Transition `ToastMessages` to tailwind

parent 42b5e11e
import classnames from 'classnames';
import { Icon } from '@hypothesis/frontend-shared';
import { Card, Icon, Link } from '@hypothesis/frontend-shared';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
......@@ -15,7 +15,7 @@ import { withServices } from '../service-context';
*/
/**
* An individual toast messagea brief and transient success or error message.
* 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.
......@@ -23,8 +23,12 @@ import { withServices } from '../service-context';
* @param {ToastMessageProps} props
*/
function ToastMessage({ message, onDismiss }) {
// Capitalize the message type for prepending
const prefix = message.type.charAt(0).toUpperCase() + message.type.slice(1);
// 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)}: `
: '';
const iconName = message.type === 'notice' ? 'cancel' : message.type;
/**
* a11y linting is disabled here: There is a click-to-remove handler on a
......@@ -36,41 +40,55 @@ function ToastMessage({ message, onDismiss }) {
*/
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,
<Card
classes={classnames('p-0 flex border', {
'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
name={iconName}
classes={classnames(
// Adjust alignment of icon to appear more aligned with text
'mt-[2px]'
)}
/>
</div>
<div
className={classnames(
'toast-message',
`toast-message--${message.type}`
// TODO: After re-factoring of Card styling, `mt-0` should not need
// !important
'grow p-3 !mt-0'
)}
data-testid="toast-message-text"
>
<div className="toast-message__type">
<Icon name={iconName} classes="toast-message__icon" />
</div>
<div className="toast-message__message">
<strong>{prefix}: </strong>
{message.message}
{message.moreInfoURL && (
<div className="toast-message__link">
<a
href={message.moreInfoURL}
onClick={
event =>
event.stopPropagation() /* consume the event so that it does not dismiss the message */
}
target="_new"
>
More info
</a>
</div>
)}
</div>
<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>
</li>
</Card>
);
}
......@@ -88,19 +106,32 @@ function ToastMessage({ message, onDismiss }) {
function ToastMessages({ toastMessenger }) {
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="ToastMessages"
className="absolute z-2 left-0 w-full space-y-2"
>
{messages.map(message => (
<ToastMessage
message={message}
<li
className={classnames(
'relative w-full container hover:cursor-pointer',
'animate-slide-in-from-right ',
{
'animate-fade-out': message.isDismissed,
}
)}
key={message.id}
onDismiss={id => toastMessenger.dismiss(id)}
/>
>
<ToastMessage
message={message}
onDismiss={id => toastMessenger.dismiss(id)}
/>
</li>
))}
</ul>
</div>
......
......@@ -76,26 +76,15 @@ describe('ToastMessages', () => {
});
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');
const messageContainer = wrapper.find('ToastMessage').getDOMNode();
act(() => {
messageContainer.simulate('click');
messageContainer.dispatchEvent(new Event('click'));
});
assert.calledOnce(fakeToastMessenger.dismiss);
......@@ -106,7 +95,7 @@ describe('ToastMessages', () => {
const wrapper = createComponent();
const link = wrapper.find('.toast-message__link a');
const link = wrapper.find('Link');
act(() => {
link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true }));
......@@ -116,38 +105,19 @@ describe('ToastMessages', () => {
});
[
{ message: fakeSuccessMessage(), className: 'toast-message--success' },
{ message: fakeErrorMessage(), className: 'toast-message--error' },
{ message: fakeNoticeMessage(), className: 'toast-message--notice' },
{ message: fakeSuccessMessage(), prefix: 'Success: ' },
{ message: fakeErrorMessage(), prefix: 'Error: ' },
{ message: fakeNoticeMessage(), prefix: '' },
].forEach(testCase => {
it('should assign a CSS class based on message type', () => {
it('should prefix the message with the 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}`
);
});
assert.include(
wrapper.text(),
`${testCase.prefix}${testCase.message.message}`
);
});
});
......@@ -179,7 +149,7 @@ describe('ToastMessages', () => {
const wrapper = createComponent();
const link = wrapper.find('.toast-message__link a');
const link = wrapper.find('Link');
assert.equal(link.props().href, 'http://www.example.com');
assert.equal(link.text(), 'More info');
});
......
......@@ -32,80 +32,3 @@
transform: rotateX(180deg);
}
}
/**
* A full-width banner with optional "type" and styled icon at left
*/
@mixin banner {
@include layout.row($align: center);
width: 100%;
background-color: var.$color-background;
border-style: solid;
border-width: 2px 0;
&--success {
border-color: var.$color-success;
}
&--error {
border-color: var.$color-error;
}
&--notice {
border-color: var.$color-notice;
}
&__type {
@include layout.row($align: center);
padding: var.$layout-space--small var.$layout-space;
color: white;
}
&--success &__type {
background-color: var.$color-success;
}
&--error &__type {
background-color: var.$color-error;
}
&--notice &__type {
background-color: var.$color-notice;
}
&__message {
padding: var.$layout-space--small;
width: 100%;
}
}
/**
* A variant of `banner` for use as a toast message container. Narrower,
* lighter border, more padding around message text.
*/
@mixin toast-message {
@include banner;
@include layout.row($align: stretch);
@include card-frame;
position: relative;
margin-bottom: var.$layout-space--small;
border-width: 1px;
&__type {
padding: var.$layout-space;
}
&__icon {
// Specific adjustments for success and error icons
margin-top: 2px;
}
&__message {
padding: var.$layout-space;
}
&__link {
text-align: right;
text-decoration: underline;
}
}
@use '../../mixins/molecules';
@use '../../mixins/layout';
@use '../mixins/responsive';
@use '../../variables' as var;
.ToastMessages {
position: absolute;
z-index: 1;
left: 0;
width: 100%;
padding: 0 var.$layout-space--xsmall;
@include responsive.tablet-and-up {
// When displaying in a wider viewport (desktop/tablet outside of sidebar)
max-width: responsive.$break-tablet;
width: responsive.$break-tablet;
padding-left: 2rem;
padding-right: 2rem;
left: 50%;
margin-left: calc(-0.5 * #{responsive.$break-tablet});
}
}
.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 molecules.toast-message;
}
@keyframes slidein {
0% {
opacity: 0;
left: 100%;
}
80% {
left: -10px;
}
100% {
left: 0;
opacity: 1;
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
......@@ -20,7 +20,6 @@
@use './PaginationNavigation';
@use './SearchInput';
@use './StyledText';
@use './ToastMessages';
@use './VersionInfo';
// TODO: Evaluate all classes below after components have been converted to
......
......@@ -19,6 +19,8 @@ export default {
'adder-pop-down': 'adder-pop-down 0.08s ease-in forwards',
'adder-pop-up': 'adder-pop-up 0.08s ease-in forwards',
'fade-in-slow': 'fade-in 1s ease-in',
'fade-out': 'fade-out 0.3s forwards',
'slide-in-from-right': 'slide-in-from-right 0.3s forwards ease-in-out',
},
borderRadius: {
// Tailwind provides a default set of border-radius utility styles
......@@ -45,6 +47,25 @@ export default {
quote: '#58cef4',
},
},
// Content in the sidebar should never exceed a max-width of `768px`, and
// that content should be auto-centered
container: {
center: true,
// Horizontal padding is larger for wider screens
padding: {
DEFAULT: '0.5rem',
lg: '4rem',
},
// By default, tailwind will provide appropriately-sized containers at
// every breakpoint available in `screens`, but for the sidebar, only
// one width matters: the width associated with the `lg` breakpoint.
// The content container should never be larger than that. `container`
// has a `max-width:100%` until the `lg` breakpoint, after which it
// never exceeds `768px`.
screens: {
lg: '768px',
},
},
fontFamily: {
mono: ['"Open Sans Mono"', 'Menlo', '"DejaVu Sans Mono"', 'monospace'],
sans: [
......@@ -123,6 +144,27 @@ export default {
opacity: '1',
},
},
'fade-out': {
'0%': {
opacity: '1',
},
'100%': {
opacity: '0',
},
},
'slide-in-from-right': {
'0%': {
opacity: '0',
left: '100%',
},
'80%': {
left: '-10px',
},
'100%': {
left: '0',
opacity: '1',
},
},
},
screens: {
touch: { raw: '(pointer: coarse)' },
......
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