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