1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 11:22:43 +02:00

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`).
This commit is contained in:
Mark Stacey 2022-03-17 08:41:50 -02:30
parent 35f51275ad
commit 4adadd3325
6 changed files with 487 additions and 0 deletions

View File

@ -0,0 +1 @@
export { default } from './show-hide-toggle';

View File

@ -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);
}
}

View File

@ -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 (
<div className={classnames('show-hide-toggle', className)}>
<input
className="show-hide-toggle__input"
id={id}
type="checkbox"
checked={shown}
onChange={onChange}
data-testid={dataTestId}
disabled={disabled}
/>
<label htmlFor={id} className="show-hide-toggle__label" title={title}>
{shown ? (
<IconEye
ariaLabel={ariaLabelShown}
className="show-hide-toggle__icon"
/>
) : (
<IconEyeSlash
ariaLabel={ariaLabelHidden}
className="show-hide-toggle__icon"
/>
)}
</label>
</div>
);
};
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;

View File

@ -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 <ShowHideToggle {...args} shown={shown} onChange={handleOnToggle} />;
};
DefaultStory.args = {
id: 'showHideToggle',
ariaLabelHidden: 'hidden',
ariaLabelShown: 'shown',
shown: false,
};
DefaultStory.storyName = 'Default';

View File

@ -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(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
title="example-title"
/>,
);
expect(queryByTitle('example-title')).toBeInTheDocument();
});
it('should set test ID', async () => {
const onChange = jest.fn();
const { queryByTestId } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
data-testid="example-test-id"
/>,
);
expect(queryByTestId('example-test-id')).toBeInTheDocument();
});
it('should show correct aria-label when shown', () => {
const onChange = jest.fn();
const { queryByLabelText } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
expect(queryByLabelText('hidden')).not.toBeInTheDocument();
expect(queryByLabelText('shown')).toBeInTheDocument();
});
it('should show correct aria-label when hidden', () => {
const onChange = jest.fn();
const { queryByLabelText } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
onChange={onChange}
/>,
);
expect(queryByLabelText('hidden')).toBeInTheDocument();
expect(queryByLabelText('shown')).not.toBeInTheDocument();
});
it('should show correct checkbox state when shown', () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
expect(queryByRole('checkbox')).toBeChecked();
});
it('should show correct checkbox state when hidden', () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
onChange={onChange}
/>,
);
expect(queryByRole('checkbox')).not.toBeChecked();
});
describe('enabled', () => {
it('should show checkbox as enabled', () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
expect(queryByRole('checkbox')).toBeEnabled();
});
it('should be accessible', () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
expect(isInaccessible(queryByRole('checkbox'))).toBeFalsy();
});
describe('shown', () => {
it('should call onChange when clicked', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
await userEvent.click(queryByRole('checkbox'));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should call onChange on space', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
onChange={onChange}
/>,
);
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(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
onChange={onChange}
/>,
);
await userEvent.click(queryByRole('checkbox'));
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should call onChange on space', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
onChange={onChange}
/>,
);
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(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
disabled
onChange={onChange}
/>,
);
expect(queryByRole('checkbox')).toBeDisabled();
});
it('should be accessible', () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
disabled
onChange={onChange}
/>,
);
expect(isInaccessible(queryByRole('checkbox'))).toBeFalsy();
});
describe('shown', () => {
it('should not call onChange when clicked', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
disabled
onChange={onChange}
/>,
);
await userEvent.click(queryByRole('checkbox'));
expect(onChange).not.toHaveBeenCalled();
});
it('should not call onChange on space', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown
disabled
onChange={onChange}
/>,
);
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(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
disabled
onChange={onChange}
/>,
);
await userEvent.click(queryByRole('checkbox'));
expect(onChange).not.toHaveBeenCalled();
});
it('should not call onChange on space', async () => {
const onChange = jest.fn();
const { queryByRole } = render(
<ShowHideToggle
id="example"
ariaLabelHidden="hidden"
ariaLabelShown="shown"
shown={false}
disabled
onChange={onChange}
/>,
);
queryByRole('checkbox').focus();
await userEvent.keyboard('[Space]');
expect(onChange).not.toHaveBeenCalled();
});
});
});
});

View File

@ -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';