| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './srp-input'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| import { ethers } from 'ethers'; | ||
| import React, { useCallback, useState } from 'react'; | ||
| import PropTypes from 'prop-types'; | ||
| import { useI18nContext } from '../../../hooks/useI18nContext'; | ||
| import TextField from '../../ui/text-field'; | ||
| import { clearClipboard } from '../../../helpers/utils/util'; | ||
| import ActionableMessage from '../../ui/actionable-message'; | ||
| import Dropdown from '../../ui/dropdown'; | ||
| import Typography from '../../ui/typography'; | ||
| import ShowHideToggle from '../../ui/show-hide-toggle'; | ||
| import { TYPOGRAPHY } from '../../../helpers/constants/design-system'; | ||
| import { parseSecretRecoveryPhrase } from './parse-secret-recovery-phrase'; | ||
|
|
||
| const { isValidMnemonic } = ethers.utils; | ||
|
|
||
| const defaultNumberOfWords = 12; | ||
|
|
||
| export default function SrpInput({ onChange }) { | ||
| const [srpError, setSrpError] = useState(''); | ||
| const [pasteFailed, setPasteFailed] = useState(false); | ||
| const [draftSrp, setDraftSrp] = useState( | ||
| new Array(defaultNumberOfWords).fill(''), | ||
| ); | ||
| const [showSrp, setShowSrp] = useState( | ||
| new Array(defaultNumberOfWords).fill(false), | ||
| ); | ||
| const [numberOfWords, setNumberOfWords] = useState(defaultNumberOfWords); | ||
|
|
||
| const t = useI18nContext(); | ||
|
|
||
| const onSrpChange = useCallback( | ||
| (newDraftSrp) => { | ||
| let newSrpError = ''; | ||
| const joinedDraftSrp = newDraftSrp.join(' '); | ||
|
|
||
| if (newDraftSrp.some((word) => word !== '')) { | ||
| if (newDraftSrp.some((word) => word === '')) { | ||
| newSrpError = t('seedPhraseReq'); | ||
| } else if (!isValidMnemonic(joinedDraftSrp)) { | ||
| newSrpError = t('invalidSeedPhrase'); | ||
| } | ||
| } | ||
|
|
||
| setDraftSrp(newDraftSrp); | ||
| setSrpError(newSrpError); | ||
| onChange(newSrpError ? '' : joinedDraftSrp); | ||
| }, | ||
| [setDraftSrp, setSrpError, t, onChange], | ||
| ); | ||
|
|
||
| const toggleShowSrp = useCallback((index) => { | ||
| setShowSrp((currentShowSrp) => { | ||
| const newShowSrp = currentShowSrp.slice(); | ||
| if (newShowSrp[index]) { | ||
| newShowSrp[index] = false; | ||
| } else { | ||
| newShowSrp.fill(false); | ||
| newShowSrp[index] = true; | ||
| } | ||
| return newShowSrp; | ||
| }); | ||
| }, []); | ||
|
|
||
| const onSrpWordChange = useCallback( | ||
| (index, newWord) => { | ||
| if (pasteFailed) { | ||
| setPasteFailed(false); | ||
| } | ||
| const newSrp = draftSrp.slice(); | ||
| newSrp[index] = newWord.trim(); | ||
| onSrpChange(newSrp); | ||
| }, | ||
| [draftSrp, onSrpChange, pasteFailed], | ||
| ); | ||
|
|
||
| const onSrpPaste = useCallback( | ||
| (rawSrp) => { | ||
| const parsedSrp = parseSecretRecoveryPhrase(rawSrp); | ||
| let newDraftSrp = parsedSrp.split(' '); | ||
|
|
||
| if (newDraftSrp.length > 24) { | ||
| setPasteFailed(true); | ||
| return; | ||
| } else if (pasteFailed) { | ||
| setPasteFailed(false); | ||
| } | ||
|
|
||
| let newNumberOfWords = numberOfWords; | ||
| if (newDraftSrp.length !== numberOfWords) { | ||
| if (newDraftSrp.length < 12) { | ||
| newNumberOfWords = 12; | ||
| } else if (newDraftSrp.length % 3 === 0) { | ||
| newNumberOfWords = newDraftSrp.length; | ||
| } else { | ||
| newNumberOfWords = | ||
| newDraftSrp.length + (3 - (newDraftSrp.length % 3)); | ||
| } | ||
| setNumberOfWords(newNumberOfWords); | ||
| } | ||
|
|
||
| if (newDraftSrp.length < newNumberOfWords) { | ||
| newDraftSrp = newDraftSrp.concat( | ||
| new Array(newNumberOfWords - newDraftSrp.length).fill(''), | ||
| ); | ||
| } | ||
| setShowSrp(new Array(newNumberOfWords).fill(false)); | ||
| onSrpChange(newDraftSrp); | ||
| clearClipboard(); | ||
| }, | ||
| [numberOfWords, onSrpChange, pasteFailed, setPasteFailed], | ||
| ); | ||
|
|
||
| const numberOfWordsOptions = []; | ||
| for (let i = 12; i <= 24; i += 3) { | ||
| numberOfWordsOptions.push({ | ||
| name: t('srpInputNumberOfWords', [`${i}`]), | ||
| value: `${i}`, | ||
| }); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="import-srp__container"> | ||
| <label className="import-srp__srp-label"> | ||
| <Typography variant={TYPOGRAPHY.H4}> | ||
| {t('secretRecoveryPhrase')} | ||
| </Typography> | ||
| </label> | ||
| <ActionableMessage | ||
| className="import-srp__paste-tip" | ||
| iconFillColor="#037dd6" // This is `--color-info-default` | ||
| message={t('srpPasteTip')} | ||
| useIcon | ||
| /> | ||
| <Dropdown | ||
| className="import-srp__number-of-words-dropdown" | ||
| onChange={(newSelectedOption) => { | ||
| const newNumberOfWords = parseInt(newSelectedOption, 10); | ||
| if (Number.isNaN(newNumberOfWords)) { | ||
| throw new Error('Unable to parse option as integer'); | ||
| } | ||
|
|
||
| let newDraftSrp = draftSrp.slice(0, newNumberOfWords); | ||
| if (newDraftSrp.length < newNumberOfWords) { | ||
| newDraftSrp = newDraftSrp.concat( | ||
| new Array(newNumberOfWords - newDraftSrp.length).fill(''), | ||
| ); | ||
| } | ||
| setNumberOfWords(newNumberOfWords); | ||
| setShowSrp(new Array(newNumberOfWords).fill(false)); | ||
| onSrpChange(newDraftSrp); | ||
| }} | ||
| options={numberOfWordsOptions} | ||
| selectedOption={`${numberOfWords}`} | ||
| /> | ||
| <div className="import-srp__srp"> | ||
| {[...Array(numberOfWords).keys()].map((index) => { | ||
| const id = `import-srp__srp-word-${index}`; | ||
| return ( | ||
| <div key={index} className="import-srp__srp-word"> | ||
| <label htmlFor={id} className="import-srp__srp-word-label"> | ||
| <Typography>{`${index + 1}.`}</Typography> | ||
| </label> | ||
| <TextField | ||
| id={id} | ||
| data-testid={id} | ||
| type={showSrp[index] ? 'text' : 'password'} | ||
| onChange={(e) => { | ||
| e.preventDefault(); | ||
| onSrpWordChange(index, e.target.value); | ||
| }} | ||
| value={draftSrp[index]} | ||
| autoComplete="off" | ||
| onPaste={(event) => { | ||
| const newSrp = event.clipboardData.getData('text'); | ||
|
|
||
| if (newSrp.trim().match(/\s/u)) { | ||
| event.preventDefault(); | ||
| onSrpPaste(newSrp); | ||
| } else { | ||
| onSrpWordChange(index, newSrp); | ||
| } | ||
| }} | ||
| /> | ||
| <ShowHideToggle | ||
| id={`${id}-checkbox`} | ||
| ariaLabelHidden={t('srpWordHidden')} | ||
| ariaLabelShown={t('srpWordShown')} | ||
| shown={showSrp[index]} | ||
| data-testid={`${id}-checkbox`} | ||
| onChange={() => toggleShowSrp(index)} | ||
| title={t('srpToggleShow')} | ||
| /> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| {srpError ? ( | ||
| <ActionableMessage | ||
| className="import-srp__srp-error" | ||
| iconFillColor="#d73a49" // This is `--color-error-default` | ||
| message={srpError} | ||
| type="danger" | ||
| useIcon | ||
| /> | ||
| ) : null} | ||
| {pasteFailed ? ( | ||
| <ActionableMessage | ||
| className="import-srp__srp-too-many-words-error" | ||
| iconFillColor="#d73a49" // This is `--color-error-default` | ||
| message={t('srpPasteFailedTooManyWords')} | ||
| primaryAction={{ | ||
| label: t('dismiss'), | ||
| onClick: () => setPasteFailed(false), | ||
| }} | ||
| type="danger" | ||
| useIcon | ||
| /> | ||
| ) : null} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| SrpInput.propTypes = { | ||
| /** | ||
| * Event handler for SRP changes. | ||
| * | ||
| * This is only called with a valid, well-formated (i.e. exactly one space | ||
| * between each word) SRP or with an empty string. | ||
| * | ||
| * This is called each time the draft SRP is updated. If the draft SRP is | ||
| * valid, this is called with a well-formatted version of that draft SRP. | ||
| * Otherwise, this is called with an empty string. | ||
| */ | ||
| onChange: PropTypes.func.isRequired, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| .import-srp { | ||
| &__container { | ||
| display: grid; | ||
| grid-template-areas: | ||
| "title dropdown" | ||
| "paste-tip paste-tip" | ||
| "input input" | ||
| "error error" | ||
| "too-many-words-error too-many-words-error"; | ||
| } | ||
|
|
||
| @media (max-width: 767px) { | ||
| &__container { | ||
| grid-template-areas: | ||
| "title" | ||
| "dropdown" | ||
| "paste-tip" | ||
| "input" | ||
| "error" | ||
| "too-many-words-error"; | ||
| } | ||
| } | ||
|
|
||
| &__srp-label { | ||
| grid-area: title; | ||
| } | ||
|
|
||
| &__number-of-words-dropdown { | ||
| grid-area: dropdown; | ||
| } | ||
|
|
||
| &__paste-tip { | ||
| margin-bottom: 8px; | ||
| grid-area: paste-tip; | ||
| width: auto; | ||
| margin-left: auto; | ||
| margin-right: auto; | ||
| } | ||
|
|
||
| &__srp { | ||
| display: grid; | ||
| grid-template-columns: 1fr 1fr 1fr; | ||
| grid-area: input; | ||
| } | ||
|
|
||
| @media (max-width: 767px) { | ||
| &__srp { | ||
| grid-template-columns: 1fr; | ||
| } | ||
| } | ||
|
|
||
| &__srp-word { | ||
| display: flex; | ||
| align-items: center; | ||
| margin: 8px; | ||
| } | ||
|
|
||
| &__srp-word-label { | ||
| width: 2em; | ||
| } | ||
|
|
||
| &__srp-error { | ||
| margin-top: 4px; | ||
| grid-area: error; | ||
| } | ||
|
|
||
| &__srp-too-many-words-error { | ||
| margin-top: 4px; | ||
| grid-area: too-many-words-error; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React from 'react'; | ||
| import SrpInput from '.'; | ||
|
|
||
| export default { | ||
| title: 'Components/App/SrpInput', | ||
| id: __filename, | ||
| component: SrpInput, | ||
| argTypes: { | ||
| onChange: { action: 'changed' }, | ||
| }, | ||
| }; | ||
|
|
||
| const Template = (args) => { | ||
| return <SrpInput {...args} />; | ||
| }; | ||
|
|
||
| export const DefaultStory = Template.bind({}); | ||
|
|
||
| DefaultStory.storyName = 'Default'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import React from 'react'; | ||
| import PropTypes from 'prop-types'; | ||
|
|
||
| const IconEyeSlash = ({ | ||
| size = 24, | ||
| color = 'currentColor', | ||
| ariaLabel, | ||
| className, | ||
| }) => ( | ||
| // This SVG is copied from `@fortawesome/fontawesome-free@5.13.0/regular/eye-slash.svg`. | ||
| <svg | ||
| width={size} | ||
| height={size} | ||
| fill={color} | ||
| className={className} | ||
| aria-label={ariaLabel} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 640 512" | ||
| > | ||
| <path d="M634 471L36 3.51A16 16 0 0 0 13.51 6l-10 12.49A16 16 0 0 0 6 41l598 467.49a16 16 0 0 0 22.49-2.49l10-12.49A16 16 0 0 0 634 471zM296.79 146.47l134.79 105.38C429.36 191.91 380.48 144 320 144a112.26 112.26 0 0 0-23.21 2.47zm46.42 219.07L208.42 260.16C210.65 320.09 259.53 368 320 368a113 113 0 0 0 23.21-2.46zM320 112c98.65 0 189.09 55 237.93 144a285.53 285.53 0 0 1-44 60.2l37.74 29.5a333.7 333.7 0 0 0 52.9-75.11 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64c-36.7 0-71.71 7-104.63 18.81l46.41 36.29c18.94-4.3 38.34-7.1 58.22-7.1zm0 288c-98.65 0-189.08-55-237.93-144a285.47 285.47 0 0 1 44.05-60.19l-37.74-29.5a333.6 333.6 0 0 0-52.89 75.1 32.35 32.35 0 0 0 0 29.19C89.72 376.41 197.08 448 320 448c36.7 0 71.71-7.05 104.63-18.81l-46.41-36.28C359.28 397.2 339.89 400 320 400z" /> | ||
| </svg> | ||
| ); | ||
|
|
||
| IconEyeSlash.propTypes = { | ||
| /** | ||
| * The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc | ||
| */ | ||
| size: PropTypes.number, | ||
| /** | ||
| * The color of the icon accepts design token css variables | ||
| */ | ||
| color: PropTypes.string, | ||
| /** | ||
| * An additional className to assign the Icon | ||
| */ | ||
| className: PropTypes.string, | ||
| /** | ||
| * The aria-label of the icon for accessibility purposes | ||
| */ | ||
| ariaLabel: PropTypes.string, | ||
| }; | ||
|
|
||
| export default IconEyeSlash; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import React from 'react'; | ||
| import PropTypes from 'prop-types'; | ||
|
|
||
| const IconEye = ({ | ||
| size = 24, | ||
| color = 'currentColor', | ||
| ariaLabel, | ||
| className, | ||
| }) => ( | ||
| // This SVG copied from `@fortawesome/fontawesome-free@5.13.0/regular/eye.svg`. | ||
| <svg | ||
| width={size} | ||
| height={size} | ||
| fill={color} | ||
| className={className} | ||
| aria-label={ariaLabel} | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 576 512" | ||
| > | ||
| <path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z" /> | ||
| </svg> | ||
| ); | ||
|
|
||
| IconEye.propTypes = { | ||
| /** | ||
| * The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc | ||
| */ | ||
| size: PropTypes.number, | ||
| /** | ||
| * The color of the icon accepts design token css variables | ||
| */ | ||
| color: PropTypes.string, | ||
| /** | ||
| * An additional className to assign the Icon | ||
| */ | ||
| className: PropTypes.string, | ||
| /** | ||
| * The aria-label of the icon for accessibility purposes | ||
| */ | ||
| ariaLabel: PropTypes.string, | ||
| }; | ||
|
|
||
| export default IconEye; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './show-hide-toggle'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |