From 8b1230acab1d7646b80f7f71b265b6050d57eee3 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Thu, 17 Mar 2022 08:41:50 -0230 Subject: [PATCH] Add `ShowHideToggle` component (#13979) Add a new component for controlling whether a field should be shown or hidden. This will be used in later PRs as a control for sensitive fields that are hidden by default. This component should be fully accessible. Both mouse and keyboard interactions have been tested, and `aria-label` attributes have been added to explain the two "eye" icons that don't have any corresponding text. Thorough unit tests have been written, testing all props except `className` (I don't know how to test that using Jest/`jsdom`). --- ui/components/ui/show-hide-toggle/index.js | 1 + ui/components/ui/show-hide-toggle/index.scss | 34 ++ .../ui/show-hide-toggle/show-hide-toggle.js | 86 +++++ .../show-hide-toggle.stories.js | 51 +++ .../show-hide-toggle/show-hide-toggle.test.js | 314 ++++++++++++++++++ ui/components/ui/ui-components.scss | 1 + 6 files changed, 487 insertions(+) create mode 100644 ui/components/ui/show-hide-toggle/index.js create mode 100644 ui/components/ui/show-hide-toggle/index.scss create mode 100644 ui/components/ui/show-hide-toggle/show-hide-toggle.js create mode 100644 ui/components/ui/show-hide-toggle/show-hide-toggle.stories.js create mode 100644 ui/components/ui/show-hide-toggle/show-hide-toggle.test.js diff --git a/ui/components/ui/show-hide-toggle/index.js b/ui/components/ui/show-hide-toggle/index.js new file mode 100644 index 000000000..21767a970 --- /dev/null +++ b/ui/components/ui/show-hide-toggle/index.js @@ -0,0 +1 @@ +export { default } from './show-hide-toggle'; diff --git a/ui/components/ui/show-hide-toggle/index.scss b/ui/components/ui/show-hide-toggle/index.scss new file mode 100644 index 000000000..88ec8a068 --- /dev/null +++ b/ui/components/ui/show-hide-toggle/index.scss @@ -0,0 +1,34 @@ +.show-hide-toggle { + position: relative; + display: inline-flex; + + &__input { + appearance: none; + + + .show-hide-toggle__label { + cursor: pointer; + user-select: none; + } + + /* Focused when tabbing with keyboard */ + &:focus, + &:focus-visible { + outline: none; + + + .show-hide-toggle__label { + outline: Highlight auto 1px; + } + } + + &:disabled { + + label { + opacity: 0.5; + cursor: auto; + } + } + } + + &__icon { + color: var(--color-icon-default); + } +} diff --git a/ui/components/ui/show-hide-toggle/show-hide-toggle.js b/ui/components/ui/show-hide-toggle/show-hide-toggle.js new file mode 100644 index 000000000..61477b205 --- /dev/null +++ b/ui/components/ui/show-hide-toggle/show-hide-toggle.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import IconEye from '../icon/icon-eye'; +import IconEyeSlash from '../icon/icon-eye-slash'; + +const ShowHideToggle = ({ + id, + shown, + onChange, + ariaLabelHidden, + ariaLabelShown, + className, + 'data-testid': dataTestId, + disabled, + title, +}) => { + return ( +
+ + +
+ ); +}; + +ShowHideToggle.propTypes = { + /** + * The id of the ShowHideToggle for htmlFor + */ + id: PropTypes.string.isRequired, + /** + * If the ShowHideToggle is in the "shown" state or not + */ + shown: PropTypes.bool.isRequired, + /** + * The onChange handler of the ShowHideToggle + */ + onChange: PropTypes.func.isRequired, + /** + * The aria-label of the icon representing the "hidden" state + */ + ariaLabelHidden: PropTypes.string.isRequired, + /** + * The aria-label of the icon representing the "shown" state + */ + ariaLabelShown: PropTypes.string.isRequired, + /** + * An additional className to give the ShowHideToggle + */ + className: PropTypes.string, + /** + * The data test id of the input + */ + 'data-testid': PropTypes.string, + /** + * Whether the input is disabled or not + */ + disabled: PropTypes.bool, + /** + * The title for the toggle. This is shown in a tooltip on hover. + */ + title: PropTypes.string, +}; + +export default ShowHideToggle; diff --git a/ui/components/ui/show-hide-toggle/show-hide-toggle.stories.js b/ui/components/ui/show-hide-toggle/show-hide-toggle.stories.js new file mode 100644 index 000000000..4eb06c5c0 --- /dev/null +++ b/ui/components/ui/show-hide-toggle/show-hide-toggle.stories.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { useArgs } from '@storybook/client-api'; +import ShowHideToggle from '.'; + +export default { + title: 'Components/UI/ShowHideToggle', // title should follow the folder structure location of the component. Don't use spaces. + id: __filename, + argTypes: { + id: { + control: 'text', + }, + ariaLabelHidden: { + control: 'text', + }, + ariaLabelShown: { + control: 'text', + }, + className: { + control: 'text', + }, + dataTestId: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + onChange: { + action: 'onChange', + }, + shown: { + control: 'boolean', + }, + }, +}; + +export const DefaultStory = (args) => { + const [{ shown }, updateArgs] = useArgs(); + const handleOnToggle = () => { + updateArgs({ shown: !shown }); + }; + return ; +}; + +DefaultStory.args = { + id: 'showHideToggle', + ariaLabelHidden: 'hidden', + ariaLabelShown: 'shown', + shown: false, +}; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/ui/show-hide-toggle/show-hide-toggle.test.js b/ui/components/ui/show-hide-toggle/show-hide-toggle.test.js new file mode 100644 index 000000000..8ead1b7b5 --- /dev/null +++ b/ui/components/ui/show-hide-toggle/show-hide-toggle.test.js @@ -0,0 +1,314 @@ +import React from 'react'; +import { isInaccessible, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ShowHideToggle from '.'; + +describe('ShowHideToggle', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should set title', async () => { + const onChange = jest.fn(); + const { queryByTitle } = render( + , + ); + + expect(queryByTitle('example-title')).toBeInTheDocument(); + }); + + it('should set test ID', async () => { + const onChange = jest.fn(); + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('example-test-id')).toBeInTheDocument(); + }); + + it('should show correct aria-label when shown', () => { + const onChange = jest.fn(); + const { queryByLabelText } = render( + , + ); + + expect(queryByLabelText('hidden')).not.toBeInTheDocument(); + expect(queryByLabelText('shown')).toBeInTheDocument(); + }); + + it('should show correct aria-label when hidden', () => { + const onChange = jest.fn(); + const { queryByLabelText } = render( + , + ); + + expect(queryByLabelText('hidden')).toBeInTheDocument(); + expect(queryByLabelText('shown')).not.toBeInTheDocument(); + }); + + it('should show correct checkbox state when shown', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(queryByRole('checkbox')).toBeChecked(); + }); + + it('should show correct checkbox state when hidden', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(queryByRole('checkbox')).not.toBeChecked(); + }); + + describe('enabled', () => { + it('should show checkbox as enabled', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(queryByRole('checkbox')).toBeEnabled(); + }); + + it('should be accessible', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(isInaccessible(queryByRole('checkbox'))).toBeFalsy(); + }); + + describe('shown', () => { + it('should call onChange when clicked', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + await userEvent.click(queryByRole('checkbox')); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should call onChange on space', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + queryByRole('checkbox').focus(); + await userEvent.keyboard('[Space]'); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('hidden', () => { + it('should call onChange when clicked', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + await userEvent.click(queryByRole('checkbox')); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('should call onChange on space', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + queryByRole('checkbox').focus(); + await userEvent.keyboard('[Space]'); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('disabled', () => { + it('should show checkbox as disabled', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(queryByRole('checkbox')).toBeDisabled(); + }); + + it('should be accessible', () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + + expect(isInaccessible(queryByRole('checkbox'))).toBeFalsy(); + }); + + describe('shown', () => { + it('should not call onChange when clicked', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + await userEvent.click(queryByRole('checkbox')); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange on space', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + queryByRole('checkbox').focus(); + await userEvent.keyboard('[Space]'); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('hidden', () => { + it('should not call onChange when clicked', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + await userEvent.click(queryByRole('checkbox')); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange on space', async () => { + const onChange = jest.fn(); + const { queryByRole } = render( + , + ); + queryByRole('checkbox').focus(); + await userEvent.keyboard('[Space]'); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/ui/components/ui/ui-components.scss b/ui/components/ui/ui-components.scss index 07d4fdb90..0a3b51552 100644 --- a/ui/components/ui/ui-components.scss +++ b/ui/components/ui/ui-components.scss @@ -44,6 +44,7 @@ @import 'radio-group/index'; @import 'readonly-input/index'; @import 'sender-to-recipient/index'; +@import 'show-hide-toggle/index.scss'; @import 'snackbar/index'; @import 'site-origin/index'; @import 'slider/index';