Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions app/components/password_toggle_component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
import { PasswordToggleElement } from '@18f/identity-password-toggle-element';

customElements.define('lg-password-toggle', PasswordToggleElement);
import '@18f/identity-password-toggle';
2 changes: 2 additions & 0 deletions app/javascript/packages/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
48 changes: 48 additions & 0 deletions app/javascript/packages/components/text-input.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<TextInput label="Input" />);

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(<TextInput label="Input" id={customId} />);

const input = getByLabelText('Input');

expect(input.id).to.equal(customId);
});

it('applies additional given classes', () => {
const customClass = 'custom-class';
const { getByLabelText } = render(<TextInput label="Input" className={customClass} />);

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(<TextInput label="Input" type={type} />);

const input = getByLabelText('Input') as HTMLInputElement;

expect(input.type).to.equal(type);
});

it('forwards ref', () => {
const ref = createRef<HTMLInputElement>();
render(<TextInput label="Input" ref={ref} />);

expect(ref.current).to.be.an.instanceOf(HTMLInputElement);
});
});
40 changes: 40 additions & 0 deletions app/javascript/packages/components/text-input.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement> {
/**
* 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<HTMLInputElement>,
) {
const instanceId = useInstanceId();
const inputId = id ?? `text-input-${instanceId}`;
const classes = ['usa-input', className].filter(Boolean).join(' ');

return (
<>
<label className="usa-label" htmlFor={inputId}>
{label}
</label>
<input ref={ref} className={classes} id={inputId} {...inputProps} />
</>
);
}

export default forwardRef(TextInput);
5 changes: 0 additions & 5 deletions app/javascript/packages/password-toggle-element/package.json

This file was deleted.

3 changes: 3 additions & 0 deletions app/javascript/packages/password-toggle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import './password-toggle-element';

export { default as PasswordToggle } from './password-toggle';
13 changes: 13 additions & 0 deletions app/javascript/packages/password-toggle/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@18f/identity-password-toggle",
"private": true,
"version": "1.0.0",
"peerDependencies": {
"react": "^17.0.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
52 changes: 52 additions & 0 deletions app/javascript/packages/password-toggle/password-toggle.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<PasswordToggle />);

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(<PasswordToggle label="Input" />);

expect(getByLabelText('Input').classList.contains('password-toggle__input')).to.be.true();
});

it('renders with custom toggle label', () => {
const { getByLabelText } = render(<PasswordToggle toggleLabel="Toggle" />);

expect(getByLabelText('Toggle').classList.contains('password-toggle__toggle')).to.be.true();
});

it('renders default toggle position', () => {
const { container } = render(<PasswordToggle />);

expect(container.querySelector('.password-toggle--toggle-top')).to.exist();
});

it('renders explicit toggle position', () => {
const { container } = render(<PasswordToggle togglePosition="bottom" />);

expect(container.querySelector('.password-toggle--toggle-bottom')).to.exist();
});

it('passes additional props to underlying text input', () => {
const type = 'password';
const { getByLabelText } = render(<PasswordToggle label="Input" type={type} />);

const input = getByLabelText('Input') as HTMLInputElement;

expect(input.type).to.equal(type);
});

it('forwards ref to the underlying text input', () => {
const ref = createRef<HTMLInputElement>();
render(<PasswordToggle ref={ref} />);

expect(ref.current).to.be.an.instanceOf(HTMLInputElement);
});
});
77 changes: 77 additions & 0 deletions app/javascript/packages/password-toggle/password-toggle.tsx
Original file line number Diff line number Diff line change
@@ -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<PasswordToggleElement> & { class: string };
}
}
}

type TogglePosition = 'top' | 'bottom';

type PasswordToggleProps = Partial<TextInputProps> & {
/**
* 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<HTMLInputElement>,
) {
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 (
<lg-password-toggle class={classes}>
<TextInput
ref={ref}
{...textInputProps}
label={label}
id={inputId}
className="password-toggle__input"
/>
<div className="password-toggle__toggle-wrapper">
<input
id={toggleId}
type="checkbox"
className="usa-checkbox__input usa-checkbox__input--bordered password-toggle__toggle"
aria-controls={inputId}
/>
<label htmlFor={toggleId} className="usa-checkbox__label password-toggle__toggle-label">
{toggleLabel}
</label>
</div>
</lg-password-toggle>
);
}

export default forwardRef(PasswordToggle);
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,9 +15,8 @@ function PasswordConfirmStep({ errors, registerField, onChange }: PasswordConfir
{error.message}
</Alert>
))}
<input
<PasswordToggle
ref={registerField('password')}
aria-label={t('idv.form.password')}
type="password"
onInput={(event: ChangeEvent<HTMLInputElement>) => {
onChange({ password: event.target.value });
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/packages/verify-flow/verify-flow.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down