Merge pull request #5645 from mozilla/issues/4929

feat(settings): Add AlertBar
This commit is contained in:
Lauren Zugai 2020-06-11 11:00:43 -05:00 коммит произвёл GitHub
Родитель 2c552b0342 8c0b2b0d30
Коммит c3571864b3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 274 добавлений и 39 удалений

Просмотреть файл

@ -31,6 +31,9 @@ module.exports = {
width: {
18: '4.5rem',
},
minWidth: {
sm: '27rem',
},
},
screens: {
mobileLandscape: '480px',

Просмотреть файл

@ -0,0 +1,64 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useCallback } from 'react';
import { storiesOf } from '@storybook/react';
import { useBooleanState } from 'fxa-react/lib/hooks';
import { AlertBar } from '.';
storiesOf('components/AlertBar', module)
.add('with a short message', () => (
<AlertBarToggle>
{({ alertBarRevealed, hideAlertBar }) =>
alertBarRevealed && (
<AlertBar onDismiss={hideAlertBar}>
<p>A short message.</p>
</AlertBar>
)
}
</AlertBarToggle>
))
.add('with a long message', () => (
<AlertBarToggle>
{({ alertBarRevealed, hideAlertBar }) =>
alertBarRevealed && (
<AlertBar onDismiss={hideAlertBar}>
<p>
Cake toffee jujubes gummi bears cheesecake cotton candy chocolate
cake. Soufflé toffee cupcake ice cream donut icing. Sweet pastry
wafer cheesecake tiramisu. Dessert carrot cake topping danish
macaroon tart halvah halvah gummies.
</p>
</AlertBar>
)
}
</AlertBarToggle>
));
type AlertBarToggleChildrenProps = {
alertBarRevealed: boolean;
hideAlertBar: Function;
showAlertBar: Function;
};
type AlertBarToggleProps = {
children: (props: AlertBarToggleChildrenProps) => React.ReactNode | null;
};
const AlertBarToggle = ({ children }: AlertBarToggleProps) => {
const [alertBarRevealed, showAlertBar, hideAlertBar] = useBooleanState(true);
const onClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
showAlertBar();
},
[showAlertBar]
);
return (
<div>
{children({ alertBarRevealed, showAlertBar, hideAlertBar })}
{!alertBarRevealed && (
<button {...{ onClick }}>Click to trigger alert bar</button>
)}
</div>
);
};

Просмотреть файл

@ -0,0 +1,58 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import AlertBar from './index';
describe('AlertBar', () => {
const onDismiss = jest.fn();
it('renders as expected', () => {
render(
<AlertBar {...{ onDismiss }}>
<div data-testid="children">Message</div>
</AlertBar>
);
expect(screen.queryByTestId('children')).toBeInTheDocument();
expect(screen.queryByTestId('alert-bar')).toHaveAttribute('role', 'alert');
expect(screen.getByTestId('alert-bar-dismiss')).toHaveAttribute(
'title',
'Close message'
);
});
it('calls onDismiss on button click', () => {
render(
<AlertBar {...{ onDismiss }}>
<div>Message</div>
</AlertBar>
);
fireEvent.click(screen.getByTestId('alert-bar-dismiss'));
expect(onDismiss).toHaveBeenCalled();
});
it('calls onDismiss on esc key press', () => {
render(
<AlertBar {...{ onDismiss }}>
<p>Message</p>
</AlertBar>
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onDismiss).toHaveBeenCalled();
});
it('shifts focus to the tab fence when rendered', () => {
render(
<AlertBar {...{ onDismiss }}>
<p>Message</p>
</AlertBar>
);
expect(document.activeElement).toBe(
screen.getByTestId('alert-bar-tab-fence')
);
});
});

Просмотреть файл

@ -0,0 +1,48 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { ReactNode } from 'react';
import { useEscKeydownEffect, useChangeFocusEffect } from '../../lib/hooks';
import { ReactComponent as CloseIcon } from 'fxa-react/images/close.svg';
type AlertBarProps = {
children: ReactNode | string;
onDismiss: Function;
};
export const AlertBar = ({ children, onDismiss }: AlertBarProps) => {
const tabFenceRef = useChangeFocusEffect();
useEscKeydownEffect(onDismiss);
return (
<div
className="flex fixed justify-center mt-2 mx-2 right-0 left-0"
role="alert"
data-testid="alert-bar"
>
<div className="max-w-2xl w-full desktop:min-w-sm flex shadow-md bg-green-500 rounded font-bold text-sm">
<div
tabIndex={0}
ref={tabFenceRef}
data-testid="alert-bar-tab-fence"
className="outline-none"
/>
<div className="flex-1 py-2 px-8 text-center">{children}</div>
<div className="flex pr-1">
<button
data-testid="alert-bar-dismiss"
className="self-center"
onClick={onDismiss as () => void}
title="Close message"
>
<CloseIcon className="w-3 h-3 m-2" role="img" />
</button>
</div>
</div>
</div>
);
};
export default AlertBar;

Просмотреть файл

@ -30,8 +30,7 @@ export const AppLayout = ({
}}
/>
<div className="max-w-screen-desktopXl w-full mx-auto flex flex-1 tablet:px-20 desktop:px-12">
{/* `desktop:transform` forces the `position: fixed` child to inherit the width */}
<div className="hidden desktop:block desktop:flex-2 desktop:transform">
<div className="hidden desktop:block desktop:flex-2">
<Nav {...{ hasSubscription, primaryEmail }} />
</div>
<main id="main" data-testid="main" className="desktop:flex-7">

Просмотреть файл

@ -3,21 +3,23 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import Modal from './index';
afterEach(cleanup);
it('renders as expected', () => {
const onDismiss = jest.fn();
const { queryByTestId } = render(
render(
<Modal headerId="some-header" descId="some-description" {...{ onDismiss }}>
<div data-testid="children">Hi mom</div>
</Modal>
);
expect(queryByTestId('children')).toBeInTheDocument();
expect(screen.queryByTestId('children')).toBeInTheDocument();
expect(screen.queryByTestId('modal-dismiss')).toHaveAttribute(
'title',
'Close modal'
);
});
it('accepts an alternate className', () => {
@ -66,5 +68,5 @@ it('shifts focus to the tab fence when opened', () => {
<div data-testid="children">Hi mom</div>
</Modal>
);
expect(document.activeElement).toBe(getByTestId('tab-fence'));
expect(document.activeElement).toBe(getByTestId('modal-tab-fence'));
});

Просмотреть файл

@ -2,8 +2,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { ReactNode, useEffect, useRef } from 'react';
import React, { ReactNode } from 'react';
import { useClickOutsideEffect } from 'fxa-react/lib/hooks';
import { useEscKeydownEffect, useChangeFocusEffect } from '../../lib/hooks';
import classNames from 'classnames';
import Portal from 'fxa-react/components/Portal';
import { ReactComponent as CloseIcon } from 'fxa-react/images/close.svg';
@ -28,25 +29,8 @@ export const Modal = ({
'data-testid': testid = 'modal',
}: ModalProps) => {
const modalInsideRef = useClickOutsideEffect<HTMLDivElement>(onDismiss);
// direct tab focus to the modal when opened for screenreaders
const tabFenceRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (tabFenceRef.current) {
tabFenceRef.current.focus();
}
}, []);
// close on esc keydown
useEffect(() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
onDismiss();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onDismiss]);
const tabFenceRef = useChangeFocusEffect();
useEscKeydownEffect(onDismiss);
return (
<Portal id="modal" {...{ headerId, descId }}>
@ -65,19 +49,16 @@ export const Modal = ({
<div
tabIndex={0}
ref={tabFenceRef}
data-testid="tab-fence"
className="w-px"
></div>
data-testid="modal-tab-fence"
className="outline-none"
/>
<div className="flex justify-end pr-2 pt-2">
<button
data-testid="modal-dismiss"
onClick={onDismiss as () => void}
title="Close modal"
>
<CloseIcon
className="w-2 h-2 m-3"
role="img"
aria-label="Close modal"
/>
<CloseIcon className="w-2 h-2 m-3" role="img" />
</button>
</div>

Просмотреть файл

@ -7,14 +7,18 @@ import { useBooleanState } from 'fxa-react/lib/hooks';
import UnitRow from '../UnitRow';
import UnitRowWithAvatar from '../UnitRowWithAvatar';
import Modal from '../Modal';
import AlertBar from '../AlertBar';
import { AccountData } from '../AccountDataHOC/gql';
export const Settings = ({ account }: { account: AccountData }) => {
const [modalRevealed, revealModal, hideModal] = useBooleanState();
const [alertBarRevealed, revealAlertBar, hideAlertBar] = useBooleanState();
const onSecondaryEmailConfirm = useCallback(() => {
console.log('confirmed - resend verification code');
hideModal();
}, [hideModal]);
revealAlertBar();
}, [hideModal, revealAlertBar]);
const modalHeaderId = 'modal-header-verify-email';
const modalDescId = 'modal-desc-verify-email';
@ -23,6 +27,21 @@ export const Settings = ({ account }: { account: AccountData }) => {
return (
<>
{/*
* While this is where the AlertBar needs to be in the DOM, it won't be composed here
* like this. We likely need some sort of alert bar root element and then AlertBar
* can return a React.Portal hooking into this element via a ref so that we can freely
* use <AlertBar> with content where needed while its placement in the DOM remains
* here. Details will be worked out in FXA-1628.
*/}
{alertBarRevealed && (
<AlertBar onDismiss={hideAlertBar}>
<p>
Check the inbox for {primaryEmail.email} to verify your primary
email.
</p>
</AlertBar>
)}
<section className="mt-11" id="profile" data-testid="settings-profile">
<h2 className="font-header font-bold ml-4 mb-4">Profile</h2>

Просмотреть файл

@ -3,10 +3,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useRef } from 'react';
import { render } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { useFocusOnTriggeringElementOnClose } from './hooks';
import {
useFocusOnTriggeringElementOnClose,
useEscKeydownEffect,
useChangeFocusEffect,
} from './hooks';
describe('useFocusOnTriggeringElementOnClose', () => {
const Subject = ({ revealed }: { revealed?: boolean }) => {
@ -34,3 +38,34 @@ describe('useFocusOnTriggeringElementOnClose', () => {
expect(document.activeElement).not.toBe(getByTestId('trigger-element'));
});
});
describe('useEscKeydownEffect', () => {
const onEscKeydown = jest.fn();
const Subject = () => {
useEscKeydownEffect(onEscKeydown);
return <div>Hi mom</div>;
};
it('calls onEscKeydown on esc key press', () => {
render(<Subject />);
expect(onEscKeydown).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onEscKeydown).toHaveBeenCalled();
});
});
describe('useChangeFocusEffect', () => {
const Subject = () => {
const elToFocusRef = useChangeFocusEffect();
return (
<div>
<a href="#">some other focusable thing</a>
<div ref={elToFocusRef} tabIndex={0} data-testid="el-to-focus" />
</div>
);
};
it('changes focus as expected', () => {
render(<Subject />);
expect(document.activeElement).toBe(screen.getByTestId('el-to-focus'));
});
});

Просмотреть файл

@ -22,3 +22,29 @@ export function useFocusOnTriggeringElementOnClose(
}
}, [revealed, triggerElement, prevRevealed]);
}
// Run a function on 'Escape' keydown.
export function useEscKeydownEffect(onEscKeydown: Function) {
useEffect(() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
onEscKeydown();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onEscKeydown]);
}
// Direct focus to this element on first render for tabbing or screenreaders.
export function useChangeFocusEffect() {
const elToFocus = useRef<HTMLDivElement>(null);
useEffect(() => {
if (elToFocus.current) {
elToFocus.current.focus();
}
}, []);
return elToFocus;
}