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 classnames from 'classnames';
import { Icon } from '@hypothesis/frontend-shared'; import { Card, Icon, Link } from '@hypothesis/frontend-shared';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../store';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
...@@ -15,7 +15,7 @@ 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. * The message may be dismissed by clicking on it.
* Otherwise, the `toastMessenger` service handles removing messages after a * Otherwise, the `toastMessenger` service handles removing messages after a
* certain amount of time. * certain amount of time.
...@@ -23,8 +23,12 @@ import { withServices } from '../service-context'; ...@@ -23,8 +23,12 @@ import { withServices } from '../service-context';
* @param {ToastMessageProps} props * @param {ToastMessageProps} props
*/ */
function ToastMessage({ message, onDismiss }) { function ToastMessage({ message, onDismiss }) {
// Capitalize the message type for prepending // Capitalize the message type for prepending; Don't prepend a message
const prefix = message.type.charAt(0).toUpperCase() + message.type.slice(1); // 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; const iconName = message.type === 'notice' ? 'cancel' : message.type;
/** /**
* a11y linting is disabled here: There is a click-to-remove handler on a * a11y linting is disabled here: There is a click-to-remove handler on a
...@@ -36,27 +40,42 @@ function ToastMessage({ message, onDismiss }) { ...@@ -36,27 +40,42 @@ function ToastMessage({ message, onDismiss }) {
*/ */
return ( return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */
<li <Card
className={classnames('toast-message-container', { classes={classnames('p-0 flex border', {
'is-dismissed': message.isDismissed, 'border-red-error': message.type === 'error',
'border-yellow-notice': message.type === 'notice',
'border-green-success': message.type === 'success',
})} })}
onClick={() => onDismiss(message.id)} 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 <div
className={classnames( className={classnames(
'toast-message', // TODO: After re-factoring of Card styling, `mt-0` should not need
`toast-message--${message.type}` // !important
'grow p-3 !mt-0'
)} )}
data-testid="toast-message-text"
> >
<div className="toast-message__type"> <strong>{prefix}</strong>
<Icon name={iconName} classes="toast-message__icon" />
</div>
<div className="toast-message__message">
<strong>{prefix}: </strong>
{message.message} {message.message}
{message.moreInfoURL && ( {message.moreInfoURL && (
<div className="toast-message__link"> <div className="text-right">
<a <Link
href={message.moreInfoURL} href={message.moreInfoURL}
onClick={ onClick={
event => event =>
...@@ -65,12 +84,11 @@ function ToastMessage({ message, onDismiss }) { ...@@ -65,12 +84,11 @@ function ToastMessage({ message, onDismiss }) {
target="_new" target="_new"
> >
More info More info
</a> </Link>
</div> </div>
)} )}
</div> </div>
</div> </Card>
</li>
); );
} }
...@@ -88,19 +106,32 @@ function ToastMessage({ message, onDismiss }) { ...@@ -88,19 +106,32 @@ function ToastMessage({ message, onDismiss }) {
function ToastMessages({ toastMessenger }) { function ToastMessages({ toastMessenger }) {
const store = useSidebarStore(); const store = useSidebarStore();
const messages = store.getToastMessages(); 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 ( return (
<div> <div>
<ul <ul
aria-live="polite" aria-live="polite"
aria-relevant="additions" aria-relevant="additions"
className="ToastMessages" className="absolute z-2 left-0 w-full space-y-2"
> >
{messages.map(message => ( {messages.map(message => (
<li
className={classnames(
'relative w-full container hover:cursor-pointer',
'animate-slide-in-from-right ',
{
'animate-fade-out': message.isDismissed,
}
)}
key={message.id}
>
<ToastMessage <ToastMessage
message={message} message={message}
key={message.id}
onDismiss={id => toastMessenger.dismiss(id)} onDismiss={id => toastMessenger.dismiss(id)}
/> />
</li>
))} ))}
</ul> </ul>
</div> </div>
......
...@@ -76,26 +76,15 @@ describe('ToastMessages', () => { ...@@ -76,26 +76,15 @@ describe('ToastMessages', () => {
}); });
describe('`ToastMessage` sub-component', () => { 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', () => { it('should dismiss the message when clicked', () => {
fakeStore.getToastMessages.returns([fakeSuccessMessage()]); fakeStore.getToastMessages.returns([fakeSuccessMessage()]);
const wrapper = createComponent(); const wrapper = createComponent();
const messageContainer = wrapper.find('ToastMessage li'); const messageContainer = wrapper.find('ToastMessage').getDOMNode();
act(() => { act(() => {
messageContainer.simulate('click'); messageContainer.dispatchEvent(new Event('click'));
}); });
assert.calledOnce(fakeToastMessenger.dismiss); assert.calledOnce(fakeToastMessenger.dismiss);
...@@ -106,7 +95,7 @@ describe('ToastMessages', () => { ...@@ -106,7 +95,7 @@ describe('ToastMessages', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const link = wrapper.find('.toast-message__link a'); const link = wrapper.find('Link');
act(() => { act(() => {
link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true })); link.getDOMNode().dispatchEvent(new Event('click', { bubbles: true }));
...@@ -116,40 +105,21 @@ describe('ToastMessages', () => { ...@@ -116,40 +105,21 @@ describe('ToastMessages', () => {
}); });
[ [
{ message: fakeSuccessMessage(), className: 'toast-message--success' }, { message: fakeSuccessMessage(), prefix: 'Success: ' },
{ message: fakeErrorMessage(), className: 'toast-message--error' }, { message: fakeErrorMessage(), prefix: 'Error: ' },
{ message: fakeNoticeMessage(), className: 'toast-message--notice' }, { message: fakeNoticeMessage(), prefix: '' },
].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 => { ].forEach(testCase => {
it('should prefix the message with the message type', () => { it('should prefix the message with the message type', () => {
fakeStore.getToastMessages.returns([testCase.message]); fakeStore.getToastMessages.returns([testCase.message]);
const wrapper = createComponent(); const wrapper = createComponent();
const messageContent = wrapper assert.include(
.find('.toast-message__message') wrapper.text(),
.first(); `${testCase.prefix}${testCase.message.message}`
assert.equal(
messageContent.text(),
`${testCase.prefix}: ${testCase.message.message}`
); );
}); });
}); });
});
[ [
{ messages: [fakeSuccessMessage()], icons: ['success'] }, { messages: [fakeSuccessMessage()], icons: ['success'] },
...@@ -179,7 +149,7 @@ describe('ToastMessages', () => { ...@@ -179,7 +149,7 @@ describe('ToastMessages', () => {
const wrapper = createComponent(); 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.props().href, 'http://www.example.com');
assert.equal(link.text(), 'More info'); assert.equal(link.text(), 'More info');
}); });
......
...@@ -32,80 +32,3 @@ ...@@ -32,80 +32,3 @@
transform: rotateX(180deg); 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 @@ ...@@ -20,7 +20,6 @@
@use './PaginationNavigation'; @use './PaginationNavigation';
@use './SearchInput'; @use './SearchInput';
@use './StyledText'; @use './StyledText';
@use './ToastMessages';
@use './VersionInfo'; @use './VersionInfo';
// TODO: Evaluate all classes below after components have been converted to // TODO: Evaluate all classes below after components have been converted to
......
...@@ -19,6 +19,8 @@ export default { ...@@ -19,6 +19,8 @@ export default {
'adder-pop-down': 'adder-pop-down 0.08s ease-in forwards', 'adder-pop-down': 'adder-pop-down 0.08s ease-in forwards',
'adder-pop-up': 'adder-pop-up 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-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: { borderRadius: {
// Tailwind provides a default set of border-radius utility styles // Tailwind provides a default set of border-radius utility styles
...@@ -45,6 +47,25 @@ export default { ...@@ -45,6 +47,25 @@ export default {
quote: '#58cef4', 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: { fontFamily: {
mono: ['"Open Sans Mono"', 'Menlo', '"DejaVu Sans Mono"', 'monospace'], mono: ['"Open Sans Mono"', 'Menlo', '"DejaVu Sans Mono"', 'monospace'],
sans: [ sans: [
...@@ -123,6 +144,27 @@ export default { ...@@ -123,6 +144,27 @@ export default {
opacity: '1', 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: { screens: {
touch: { raw: '(pointer: coarse)' }, 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