diff --git a/app/components/password_toggle_component.ts b/app/components/password_toggle_component.ts index f5e3ff3cfc3..a714788b184 100644 --- a/app/components/password_toggle_component.ts +++ b/app/components/password_toggle_component.ts @@ -1,3 +1 @@ -import { PasswordToggleElement } from '@18f/identity-password-toggle-element'; - -customElements.define('lg-password-toggle', PasswordToggleElement); +import '@18f/identity-password-toggle'; diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 37a884ddf4c..a21a3e58be6 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -5,7 +5,9 @@ export { default as Icon } from './icon'; export { default as FullScreen } from './full-screen'; export { default as PageHeading } from './page-heading'; export { default as SpinnerDots } from './spinner-dots'; +export { default as TextInput } from './text-input'; export { default as TroubleshootingOptions } from './troubleshooting-options'; export type { ButtonProps } from './button'; export type { FullScreenRefHandle } from './full-screen'; +export type { TextInputProps } from './text-input'; diff --git a/app/javascript/packages/components/text-input.spec.tsx b/app/javascript/packages/components/text-input.spec.tsx new file mode 100644 index 00000000000..310a3cab1d6 --- /dev/null +++ b/app/javascript/packages/components/text-input.spec.tsx @@ -0,0 +1,48 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import TextInput from './text-input'; + +describe('TextInput', () => { + it('renders with an associated label', () => { + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input).to.be.an.instanceOf(HTMLInputElement); + expect(input.classList.contains('usa-input')).to.be.true(); + }); + + it('uses an explicitly-provided ID', () => { + const customId = 'custom-id'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input.id).to.equal(customId); + }); + + it('applies additional given classes', () => { + const customClass = 'custom-class'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect([...input.classList.values()]).to.have.all.members(['usa-input', customClass]); + }); + + it('applies additional input attributes', () => { + const type = 'password'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input') as HTMLInputElement; + + expect(input.type).to.equal(type); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(); + + expect(ref.current).to.be.an.instanceOf(HTMLInputElement); + }); +}); diff --git a/app/javascript/packages/components/text-input.tsx b/app/javascript/packages/components/text-input.tsx new file mode 100644 index 00000000000..31f07aa42a8 --- /dev/null +++ b/app/javascript/packages/components/text-input.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react'; +import type { InputHTMLAttributes, ForwardedRef } from 'react'; +import { useInstanceId } from '@18f/identity-react-hooks'; + +export interface TextInputProps extends InputHTMLAttributes { + /** + * Text of label associated with input. + */ + label: string; + + /** + * Optional explicit ID to use in place of default behavior. + */ + id?: string; + + /** + * Additional class name to be applied to the input element. + */ + className?: string; +} + +function TextInput( + { label, id, className, ...inputProps }: TextInputProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = id ?? `text-input-${instanceId}`; + const classes = ['usa-input', className].filter(Boolean).join(' '); + + return ( + <> + + + + ); +} + +export default forwardRef(TextInput); diff --git a/app/javascript/packages/password-toggle-element/package.json b/app/javascript/packages/password-toggle-element/package.json deleted file mode 100644 index 18528af20db..00000000000 --- a/app/javascript/packages/password-toggle-element/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@18f/identity-password-toggle-element", - "private": true, - "version": "1.0.0" -} diff --git a/app/javascript/packages/password-toggle/index.ts b/app/javascript/packages/password-toggle/index.ts new file mode 100644 index 00000000000..f1516b25fad --- /dev/null +++ b/app/javascript/packages/password-toggle/index.ts @@ -0,0 +1,3 @@ +import './password-toggle-element'; + +export { default as PasswordToggle } from './password-toggle'; diff --git a/app/javascript/packages/password-toggle/package.json b/app/javascript/packages/password-toggle/package.json new file mode 100644 index 00000000000..b492c82a94f --- /dev/null +++ b/app/javascript/packages/password-toggle/package.json @@ -0,0 +1,13 @@ +{ + "name": "@18f/identity-password-toggle", + "private": true, + "version": "1.0.0", + "peerDependencies": { + "react": "^17.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/app/javascript/packages/password-toggle-element/index.spec.ts b/app/javascript/packages/password-toggle/password-toggle-element.spec.ts similarity index 87% rename from app/javascript/packages/password-toggle-element/index.spec.ts rename to app/javascript/packages/password-toggle/password-toggle-element.spec.ts index f2571d73a66..bb3ec06f2ba 100644 --- a/app/javascript/packages/password-toggle-element/index.spec.ts +++ b/app/javascript/packages/password-toggle/password-toggle-element.spec.ts @@ -1,16 +1,11 @@ import userEvent from '@testing-library/user-event'; import { getByLabelText } from '@testing-library/dom'; -import { PasswordToggleElement } from './index'; +import './password-toggle-element'; +import type PasswordToggleElement from './password-toggle-element'; describe('PasswordToggleElement', () => { let idCounter = 0; - before(() => { - if (!customElements.get('lg-password-toggle')) { - customElements.define('lg-password-toggle', PasswordToggleElement); - } - }); - function createElement() { const element = document.createElement('lg-password-toggle') as PasswordToggleElement; const idSuffix = ++idCounter; diff --git a/app/javascript/packages/password-toggle-element/index.ts b/app/javascript/packages/password-toggle/password-toggle-element.ts similarity index 67% rename from app/javascript/packages/password-toggle-element/index.ts rename to app/javascript/packages/password-toggle/password-toggle-element.ts index c66933fbaed..efd9ec4c58f 100644 --- a/app/javascript/packages/password-toggle-element/index.ts +++ b/app/javascript/packages/password-toggle/password-toggle-element.ts @@ -12,7 +12,7 @@ interface PasswordToggleElements { input: HTMLInputElement; } -export class PasswordToggleElement extends HTMLElement { +class PasswordToggleElement extends HTMLElement { connectedCallback() { this.elements.toggle.addEventListener('change', () => this.setInputType()); this.setInputType(); @@ -30,3 +30,15 @@ export class PasswordToggleElement extends HTMLElement { this.elements.input.type = this.elements.toggle.checked ? 'text' : 'password'; } } + +declare global { + interface HTMLElementTagNameMap { + 'lg-password-toggle': PasswordToggleElement; + } +} + +if (!customElements.get('lg-password-toggle')) { + customElements.define('lg-password-toggle', PasswordToggleElement); +} + +export default PasswordToggleElement; diff --git a/app/javascript/packages/password-toggle/password-toggle.spec.tsx b/app/javascript/packages/password-toggle/password-toggle.spec.tsx new file mode 100644 index 00000000000..9dc744c1684 --- /dev/null +++ b/app/javascript/packages/password-toggle/password-toggle.spec.tsx @@ -0,0 +1,52 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import PasswordToggle from './password-toggle'; + +describe('PasswordToggle', () => { + it('renders with default labels', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('components.password_toggle.label')).to.exist(); + expect(getByLabelText('components.password_toggle.toggle_label')).to.exist(); + }); + + it('renders with custom input label', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('Input').classList.contains('password-toggle__input')).to.be.true(); + }); + + it('renders with custom toggle label', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('Toggle').classList.contains('password-toggle__toggle')).to.be.true(); + }); + + it('renders default toggle position', () => { + const { container } = render(); + + expect(container.querySelector('.password-toggle--toggle-top')).to.exist(); + }); + + it('renders explicit toggle position', () => { + const { container } = render(); + + expect(container.querySelector('.password-toggle--toggle-bottom')).to.exist(); + }); + + it('passes additional props to underlying text input', () => { + const type = 'password'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input') as HTMLInputElement; + + expect(input.type).to.equal(type); + }); + + it('forwards ref to the underlying text input', () => { + const ref = createRef(); + render(); + + expect(ref.current).to.be.an.instanceOf(HTMLInputElement); + }); +}); diff --git a/app/javascript/packages/password-toggle/password-toggle.tsx b/app/javascript/packages/password-toggle/password-toggle.tsx new file mode 100644 index 00000000000..60db2595a56 --- /dev/null +++ b/app/javascript/packages/password-toggle/password-toggle.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from 'react'; +import type { HTMLAttributes, ForwardedRef } from 'react'; +import { t } from '@18f/identity-i18n'; +import { TextInput } from '@18f/identity-components'; +import { useInstanceId } from '@18f/identity-react-hooks'; +import type { TextInputProps } from '@18f/identity-components'; +import './password-toggle-element'; +import type PasswordToggleElement from './password-toggle-element'; + +declare global { + namespace JSX { + interface IntrinsicElements { + 'lg-password-toggle': HTMLAttributes & { class: string }; + } + } +} + +type TogglePosition = 'top' | 'bottom'; + +type PasswordToggleProps = Partial & { + /** + * Input label text. + */ + label?: string; + + /** + * Toggle label text. + */ + toggleLabel?: string; + + /** + * Placement of toggle relative to the input. + */ + togglePosition?: TogglePosition; +}; + +function PasswordToggle( + { + label = t('components.password_toggle.label'), + toggleLabel = t('components.password_toggle.toggle_label'), + togglePosition = 'top', + ...textInputProps + }: PasswordToggleProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = `password-toggle-input-${instanceId}`; + const toggleId = `password-toggle-${instanceId}`; + + const classes = + togglePosition === 'top' ? 'password-toggle--toggle-top' : 'password-toggle--toggle-bottom'; + + return ( + + +
+ + +
+
+ ); +} + +export default forwardRef(PasswordToggle); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx index fb2fc475adf..4b13053ccbb 100644 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react'; -import { t } from '@18f/identity-i18n'; +import { PasswordToggle } from '@18f/identity-password-toggle'; import { FormStepsButton } from '@18f/identity-form-steps'; import { Alert } from '@18f/identity-components'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; @@ -15,9 +15,8 @@ function PasswordConfirmStep({ errors, registerField, onChange }: PasswordConfir {error.message} ))} - ) => { onChange({ password: event.target.value }); diff --git a/app/javascript/packages/verify-flow/verify-flow.spec.tsx b/app/javascript/packages/verify-flow/verify-flow.spec.tsx index f314f3b35f8..74c1bf5738b 100644 --- a/app/javascript/packages/verify-flow/verify-flow.spec.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.spec.tsx @@ -32,7 +32,7 @@ describe('VerifyFlow', () => { expect(document.title).to.equal('idv.titles.session.review - Example App'); expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm visited'); expect(window.location.pathname).to.equal('/password_confirm'); - await userEvent.type(getByLabelText('idv.form.password'), 'password'); + await userEvent.type(getByLabelText('components.password_toggle.label'), 'password'); await userEvent.click(getByText('forms.buttons.continue')); expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm submitted');