mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 18:00:18 +01: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:
parent
bba8b214b9
commit
8b1230acab
1
ui/components/ui/show-hide-toggle/index.js
Normal file
1
ui/components/ui/show-hide-toggle/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './show-hide-toggle';
|
34
ui/components/ui/show-hide-toggle/index.scss
Normal file
34
ui/components/ui/show-hide-toggle/index.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
86
ui/components/ui/show-hide-toggle/show-hide-toggle.js
Normal file
86
ui/components/ui/show-hide-toggle/show-hide-toggle.js
Normal 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;
|
@ -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';
|
314
ui/components/ui/show-hide-toggle/show-hide-toggle.test.js
Normal file
314
ui/components/ui/show-hide-toggle/show-hide-toggle.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -44,6 +44,7 @@
|
|||||||
@import 'radio-group/index';
|
@import 'radio-group/index';
|
||||||
@import 'readonly-input/index';
|
@import 'readonly-input/index';
|
||||||
@import 'sender-to-recipient/index';
|
@import 'sender-to-recipient/index';
|
||||||
|
@import 'show-hide-toggle/index.scss';
|
||||||
@import 'snackbar/index';
|
@import 'snackbar/index';
|
||||||
@import 'site-origin/index';
|
@import 'site-origin/index';
|
||||||
@import 'slider/index';
|
@import 'slider/index';
|
||||||
|
Loading…
Reference in New Issue
Block a user