diff --git a/app/components/validated_field_component.js b/app/components/validated_field_component.js index ce33f96a7f4..2a284972c93 100644 --- a/app/components/validated_field_component.js +++ b/app/components/validated_field_component.js @@ -1,3 +1 @@ -import { ValidatedField } from '@18f/identity-validated-field'; - -customElements.define('lg-validated-field', ValidatedField); +import '@18f/identity-validated-field'; diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index 452ea033b23..2388ebc83aa 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -203,6 +203,19 @@ describe('FormSteps', () => { }); }); + it('will submit the form by enter press in an input', async () => { + const onComplete = sinon.spy(); + const { getByText, getByLabelText } = render( + , + ); + + await userEvent.click(getByText('forms.buttons.continue')); + await userEvent.type(getByLabelText('Second Input One'), 'one'); + await userEvent.type(getByLabelText('Second Input Two'), 'two{Enter}'); + + expect(getByText('Last Title')).to.be.ok(); + }); + it('prompts on navigate if values have been assigned', async () => { const { getByText, getByLabelText } = render(); @@ -323,6 +336,31 @@ describe('FormSteps', () => { expect(document.activeElement).to.equal(getByText('Last Title')); }); + it('respects native custom input validity', async () => { + const { getByRole } = render(); + + await userEvent.click(getByRole('button', { name: 'forms.buttons.continue' })); + const inputOne = getByRole('textbox', { name: 'Second Input One' }) as HTMLInputElement; + const inputTwo = getByRole('textbox', { name: 'Second Input Two' }) as HTMLInputElement; + + // Make inputs otherwise valid. + await userEvent.type(inputOne, 'one'); + await userEvent.type(inputTwo, 'two'); + + // Add custom validity error. + const checkValidity = () => { + inputOne.setCustomValidity('Custom Error'); + return false; + }; + inputOne.reportValidity = checkValidity; + inputOne.checkValidity = checkValidity; + + await userEvent.click(getByRole('button', { name: 'forms.buttons.continue' })); + + expect(inputOne.hasAttribute('data-is-error')).to.be.true(); + expect(document.activeElement).to.equal(inputOne); + }); + it('distinguishes empty errors from progressive error removal', async () => { const { getByText, getByLabelText, container } = render(); @@ -340,6 +378,9 @@ describe('FormSteps', () => { const { getByLabelText, getByText, getByRole } = render( { field: 'secondInputOne', error: new FormError(), }, + { + field: 'secondInputTwo', + error: new FormError(), + }, ]} onComplete={onComplete} />, ); - // Field associated errors are handled by the field. There should only be one. + // Field associated errors are handled by the field. const inputOne = getByLabelText('Second Input One'); const inputTwo = getByLabelText('Second Input Two'); expect(inputOne.matches('[data-is-error]')).to.be.true(); - expect(inputTwo.matches('[data-is-error]')).to.be.false(); + expect(inputTwo.matches('[data-is-error]')).to.be.true(); // Attempting to submit without adjusting field value does not submit and shows error. await userEvent.click(getByText('forms.buttons.submit.default')); expect(onComplete.called).to.be.false(); await waitFor(() => expect(document.activeElement).to.equal(inputOne)); - // Changing the value for the field should unset the error. + // Changing the value for the first field should unset the first error. await userEvent.type(inputOne, 'one'); expect(inputOne.matches('[data-is-error]')).to.be.false(); - expect(inputTwo.matches('[data-is-error]')).to.be.false(); + expect(inputTwo.matches('[data-is-error]')).to.be.true(); // Default required validation should still happen and take the place of any unknown errors. await userEvent.click(getByText('forms.buttons.submit.default')); @@ -378,7 +423,7 @@ describe('FormSteps', () => { expect(inputTwo.matches('[data-is-error]')).to.be.true(); expect(() => getByRole('alert')).to.throw(); - // Changing the value for the field should unset the error. + // Changing the value for the second field should unset the second error. await userEvent.type(inputTwo, 'two'); expect(inputOne.matches('[data-is-error]')).to.be.false(); expect(inputTwo.matches('[data-is-error]')).to.be.false(); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index ed0d76b9f36..76c43ceedaa 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import type { RefCallback, FormEventHandler, FC } from 'react'; +import type { FormEventHandler, RefCallback, FC } from 'react'; import { Alert } from '@18f/identity-components'; import { useDidUpdateEffect, useIfStillMounted } from '@18f/identity-react-hooks'; import RequiredValueMissingError from './required-value-missing-error'; @@ -30,7 +30,7 @@ interface FormStepRegisterFieldOptions { export type RegisterFieldCallback = ( field: string, options?: Partial, -) => undefined | RefCallback; +) => undefined | RefCallback; export type OnErrorCallback = (error: Error, options?: { field?: string | null }) => void; @@ -87,7 +87,7 @@ interface FieldsRefEntry { /** * Ref callback. */ - refCallback: RefCallback; + refCallback: RefCallback; /** * Whether field is required. @@ -97,7 +97,7 @@ interface FieldsRefEntry { /** * Element assigned by ref callback. */ - element: HTMLElement | null; + element: HTMLInputElement | null; } interface FormStepsProps { @@ -195,7 +195,11 @@ function FormSteps({ const ifStillMounted = useIfStillMounted(); useEffect(() => { if (activeErrors.length && didSubmitWithErrors.current) { - getFieldActiveErrorFieldElement(activeErrors, fields.current)?.focus(); + const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current); + if (activeErrorFieldElement) { + activeErrorFieldElement.reportValidity(); + activeErrorFieldElement.focus(); + } } didSubmitWithErrors.current = false; @@ -242,8 +246,19 @@ function FormSteps({ const { element, isRequired } = fields.current[key]; const isActive = !!element; - if (isActive && isRequired && !values[key]) { - result = result.concat({ field: key, error: new RequiredValueMissingError() }); + let error: Error | undefined; + if (isActive) { + element.checkValidity(); + + if (element.validationMessage) { + error = new Error(element.validationMessage); + } else if (isRequired && !values[key]) { + error = new RequiredValueMissingError(); + } + } + + if (error) { + result = result.concat({ field: key, error }); } return result; @@ -275,8 +290,8 @@ function FormSteps({ const nextActiveErrors = getValidationErrors(); setActiveErrors(nextActiveErrors); - didSubmitWithErrors.current = true; if (nextActiveErrors.length) { + didSubmitWithErrors.current = true; return; } @@ -300,7 +315,7 @@ function FormSteps({ const isLastStep = stepIndex + 1 === steps.length; return ( -
+ {promptOnNavigate && Object.keys(values).length > 0 && } {stepErrors.map((error) => ( diff --git a/app/javascript/packages/validated-field/index.ts b/app/javascript/packages/validated-field/index.ts new file mode 100644 index 00000000000..726a6cc890d --- /dev/null +++ b/app/javascript/packages/validated-field/index.ts @@ -0,0 +1,5 @@ +import './validated-field-element'; + +export { default as ValidatedField } from './validated-field'; + +export type { ValidatedFieldValidator } from './validated-field'; diff --git a/app/javascript/packages/validated-field/package.json b/app/javascript/packages/validated-field/package.json index 4838734149e..b7f3f634e5d 100644 --- a/app/javascript/packages/validated-field/package.json +++ b/app/javascript/packages/validated-field/package.json @@ -1,5 +1,13 @@ { "name": "@18f/identity-validated-field", "private": true, - "version": "1.0.0" + "version": "1.0.0", + "peerDependencies": { + "react": "^17.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } } diff --git a/app/javascript/packages/validated-field/index.spec.js b/app/javascript/packages/validated-field/validated-field-element.spec.ts similarity index 74% rename from app/javascript/packages/validated-field/index.spec.js rename to app/javascript/packages/validated-field/validated-field-element.spec.ts index 1d3b5ca0161..107def2c8c9 100644 --- a/app/javascript/packages/validated-field/index.spec.js +++ b/app/javascript/packages/validated-field/validated-field-element.spec.ts @@ -1,13 +1,9 @@ import sinon from 'sinon'; import { getByRole, getByText } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; -import { ValidatedField } from '.'; - -describe('ValidatedField', () => { - before(() => { - customElements.define('lg-validated-field', ValidatedField); - }); +import './validated-field-element'; +describe('ValidatedFieldElement', () => { let idCounter = 0; function createAndConnectElement({ hasInitialError = false } = {}) { @@ -45,11 +41,9 @@ describe('ValidatedField', () => { it('shows error state and focuses on form validation', () => { const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); expect(input.classList.contains('usa-input--error')).to.be.true(); @@ -63,13 +57,11 @@ describe('ValidatedField', () => { it('shows custom validity as message content', () => { const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; input.value = 'a'; input.setCustomValidity('custom validity'); - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); expect(getByText(element, 'custom validity')).to.be.ok(); @@ -78,11 +70,9 @@ describe('ValidatedField', () => { it('clears existing validation state on input', async () => { const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); await userEvent.type(input, '5'); @@ -96,11 +86,9 @@ describe('ValidatedField', () => { const firstElement = createAndConnectElement(); createAndConnectElement(); - /** @type {HTMLInputElement} */ - const firstInput = getByRole(firstElement, 'textbox'); + const firstInput = getByRole(firstElement, 'textbox') as HTMLInputElement; - /** @type {HTMLFormElement} */ - const form = document.querySelector('form'); + const form = document.querySelector('form') as HTMLFormElement; form.checkValidity(); @@ -111,11 +99,9 @@ describe('ValidatedField', () => { it('clears existing validation state on input', async () => { const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); await userEvent.type(input, '5'); @@ -131,12 +117,10 @@ describe('ValidatedField', () => { const inputWidth = 280; const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; sinon.stub(input, 'offsetWidth').value(inputWidth); - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); const message = getByText(element, 'This field is required'); @@ -148,12 +132,10 @@ describe('ValidatedField', () => { it('does not set max width on error message', () => { const element = createAndConnectElement(); - /** @type {HTMLInputElement} */ - const input = getByRole(element, 'textbox'); + const input = getByRole(element, 'textbox') as HTMLInputElement; input.type = 'checkbox'; - /** @type {HTMLFormElement} */ - const form = element.parentNode; + const form = element.parentNode as HTMLFormElement; form.checkValidity(); const message = getByText(element, 'This field is required'); diff --git a/app/javascript/packages/validated-field/index.js b/app/javascript/packages/validated-field/validated-field-element.ts similarity index 76% rename from app/javascript/packages/validated-field/index.js rename to app/javascript/packages/validated-field/validated-field-element.ts index 4fc77447f8f..0e45b16108a 100644 --- a/app/javascript/packages/validated-field/index.js +++ b/app/javascript/packages/validated-field/validated-field-element.ts @@ -1,8 +1,6 @@ /** * Set of text-like input types, used in determining whether the width of the error message should * be constrained to match the width of the input. - * - * @type {Set} */ const TEXT_LIKE_INPUT_TYPES = new Set([ 'date', @@ -18,16 +16,20 @@ const TEXT_LIKE_INPUT_TYPES = new Set([ 'url', ]); -export class ValidatedField extends HTMLElement { - /** @type {Partial} */ - errorStrings = {}; +class ValidatedFieldElement extends HTMLElement { + errorStrings: Partial = {}; + + input: HTMLInputElement | null; + + inputWrapper: HTMLElement | null; + + errorMessage: HTMLElement | null; + + descriptorId?: string | null; connectedCallback() { - /** @type {HTMLInputElement?} */ this.input = this.querySelector('.validated-field__input'); - /** @type {HTMLElement?} */ this.inputWrapper = this.querySelector('.validated-field__input-wrapper'); - /** @type {HTMLElement?} */ this.errorMessage = this.querySelector('.usa-error-message'); this.descriptorId = this.input?.getAttribute('aria-describedby'); try { @@ -46,9 +48,9 @@ export class ValidatedField extends HTMLElement { * Handles an invalid event, rendering or hiding an error message based on the input's current * validity. * - * @param {Event} event Invalid event. + * @param event Invalid event. */ - toggleErrorMessage(event) { + toggleErrorMessage(event: Event) { event.preventDefault(); const errorMessage = this.getNormalizedValidationMessage(this.input); @@ -61,9 +63,9 @@ export class ValidatedField extends HTMLElement { /** * Renders the given message as an error, if present. Otherwise, hides any visible error message. * - * @param {string?=} message Error message to show, or empty to hide. + * @param message Error message to show, or empty to hide. */ - setErrorMessage(message) { + setErrorMessage(message?: string | null) { if (message) { this.getOrCreateErrorMessageElement().textContent = message; if (!document.activeElement?.classList.contains('usa-input--error')) { @@ -78,9 +80,9 @@ export class ValidatedField extends HTMLElement { /** * Sets input attributes corresponding to given validity state. * - * @param {boolean} isValid Whether input is valid. + * @param isValid Whether input is valid. */ - setInputIsValid(isValid) { + setInputIsValid(isValid: boolean) { this.input?.classList.toggle('usa-input--error', !isValid); this.input?.setAttribute('aria-invalid', String(!isValid)); } @@ -89,11 +91,11 @@ export class ValidatedField extends HTMLElement { * Returns a validation message for the given input, normalized to use customized error strings. * An empty string is returned for a valid input. * - * @param {HTMLInputElement?=} input Input element. + * @param input Input element. * - * @return {string} Validation message. + * @return Validation message. */ - getNormalizedValidationMessage(input) { + getNormalizedValidationMessage(input?: HTMLInputElement | null): string { if (!input || input.validity.valid) { return ''; } @@ -111,9 +113,9 @@ export class ValidatedField extends HTMLElement { * Returns an error message element. If one doesn't already exist, it is created and appended to * the root. * - * @returns {Element} Error message element. + * @return Error message element. */ - getOrCreateErrorMessageElement() { + getOrCreateErrorMessageElement(): Element { if (!this.errorMessage) { this.errorMessage = this.ownerDocument.createElement('div'); this.errorMessage.classList.add('usa-error-message'); @@ -130,3 +132,15 @@ export class ValidatedField extends HTMLElement { return this.errorMessage; } } + +declare global { + interface HTMLElementTagNameMap { + 'lg-validated-field': ValidatedFieldElement; + } +} + +if (!customElements.get('lg-validated-field')) { + customElements.define('lg-validated-field', ValidatedFieldElement); +} + +export default ValidatedFieldElement; diff --git a/app/javascript/packages/validated-field/validated-field.spec.tsx b/app/javascript/packages/validated-field/validated-field.spec.tsx new file mode 100644 index 00000000000..ec145e41129 --- /dev/null +++ b/app/javascript/packages/validated-field/validated-field.spec.tsx @@ -0,0 +1,84 @@ +import sinon from 'sinon'; +import { render } from '@testing-library/react'; +import ValidatedField from './validated-field'; + +describe('ValidatedField', () => { + it('renders a validated input', () => { + const { getByRole } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.getAttribute('aria-invalid')).to.equal('false'); + expect(input.checkValidity()).to.be.true(); + }); + + it('validates using validate prop', () => { + const validate = sinon.stub().throws(new Error('oops')); + const { getByRole } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.checkValidity()).to.be.false(); + expect(input.validationMessage).to.equal('oops'); + }); + + it('validates using native validation', () => { + const validate = sinon.stub(); + const { getByRole } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.checkValidity()).to.be.false(); + expect(input.validity.valueMissing).to.be.true(); + }); + + it('is described by associated error message', () => { + const validate = sinon.stub().throws(new Error('oops')); + const { getByRole, baseElement } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + input.reportValidity(); + + const errorMessage = baseElement.querySelector(`#${input.getAttribute('aria-describedby')}`)!; + expect(errorMessage.classList.contains('usa-error-message')).to.be.true(); + expect(errorMessage.textContent).to.equal('oops'); + }); + + it('merges classNames', () => { + const { getByRole } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.classList.contains('validated-field__input')).to.be.true(); + expect(input.classList.contains('my-custom-class')).to.be.true(); + }); + + context('with children', () => { + it('validates using validate prop', () => { + const validate = sinon.stub().throws(new Error('oops')); + const { getByRole } = render( + + + , + ); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.checkValidity()).to.be.false(); + expect(input.validationMessage).to.equal('oops'); + }); + + it('merges classNames', () => { + const { getByRole } = render( + + + , + ); + + const input = getByRole('textbox') as HTMLInputElement; + + expect(input.classList.contains('validated-field__input')).to.be.true(); + expect(input.classList.contains('my-custom-class')).to.be.true(); + }); + }); +}); diff --git a/app/javascript/packages/validated-field/validated-field.tsx b/app/javascript/packages/validated-field/validated-field.tsx new file mode 100644 index 00000000000..20983beee9c --- /dev/null +++ b/app/javascript/packages/validated-field/validated-field.tsx @@ -0,0 +1,93 @@ +import { useRef, useEffect, Children, cloneElement, createElement } from 'react'; +import type { + MutableRefObject, + ReactNode, + HTMLAttributes, + InputHTMLAttributes, + ReactHTMLElement, +} from 'react'; +import { useInstanceId } from '@18f/identity-react-hooks'; +import './validated-field-element'; +import type ValidatedFieldElement from './validated-field-element'; + +export type ValidatedFieldValidator = (value: string) => void; + +interface ValidatedFieldProps { + /** + * Callback to check validity of the current value, throwing an error with the message to be shown + * if invalid. + */ + validate?: ValidatedFieldValidator; + + /** + * Optional input to use in place of the default rendered input. The input will be cloned and + * extended with behaviors for validation. + */ + children?: ReactNode; +} + +declare global { + namespace JSX { + interface IntrinsicElements { + 'lg-validated-field': HTMLAttributes & { + class?: string; + ref?: MutableRefObject; + }; + } + } +} + +function ValidatedField({ + validate = () => {}, + children, + ...inputProps +}: ValidatedFieldProps & InputHTMLAttributes) { + const fieldRef = useRef(); + const instanceId = useInstanceId(); + useEffect(() => { + if (fieldRef.current && fieldRef.current.input) { + const { input } = fieldRef.current; + input.checkValidity = () => { + let nextError: string = ''; + try { + validate(input.value); + } catch (error) { + nextError = error.message; + } + + input.setCustomValidity(nextError); + return !nextError && HTMLInputElement.prototype.checkValidity.call(input); + }; + + input.reportValidity = () => { + input.checkValidity(); + return HTMLInputElement.prototype.reportValidity.call(input); + }; + } + }, [validate]); + + const errorId = `validated-field-error-${instanceId}`; + + const input: ReactHTMLElement = children + ? (Children.only(children) as ReactHTMLElement) + : createElement('input'); + + const inputClasses = ['validated-field__input', inputProps.className, input.props.className] + .filter(Boolean) + .join(' '); + + return ( + +
+ {cloneElement(input, { + ...inputProps, + 'aria-invalid': false, + 'aria-describedby': errorId, + className: inputClasses, + })} +
+
+ ); +} + +export default ValidatedField; diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/index.tsx index 45cbe226dfe..f13905cd41d 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/index.tsx @@ -4,6 +4,8 @@ import { STEPS } from './steps'; export interface VerifyFlowValues { personalKey?: string; + + personalKeyConfirm?: string; } interface VerifyFlowProps { diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx index 1cd305b4c78..4eb2e918a18 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.spec.tsx @@ -1,6 +1,7 @@ import sinon from 'sinon'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { FormSteps } from '@18f/identity-form-steps'; import PersonalKeyConfirmStep from './personal-key-confirm-step'; describe('PersonalKeyConfirmStep', () => { @@ -34,4 +35,40 @@ describe('PersonalKeyConfirmStep', () => { expect(toPreviousStep).to.have.been.called(); }); + + it('allows the user to continue only with a correct value', async () => { + const onComplete = sinon.spy(); + const { getByLabelText, getAllByText, container } = render( + , + ); + + const input = getByLabelText('forms.personal_key.confirmation_label'); + const submitButton = getAllByText('forms.buttons.submit.default')[1]; + await userEvent.click(submitButton); + + expect(onComplete).not.to.have.been.called(); + expect(container.ownerDocument.activeElement).to.equal(input); + let errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); + expect(errorMessage!.textContent).to.equal('users.personal_key.confirmation_error'); + + await userEvent.type(input, '0000-0000-0000-000'); + errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); + expect(errorMessage).to.not.exist(); + await userEvent.type(input, '{Enter}'); + expect(onComplete).not.to.have.been.called(); + errorMessage = document.getElementById(input.getAttribute('aria-describedby')!); + expect(errorMessage!.textContent).to.equal('users.personal_key.confirmation_error'); + + await userEvent.type(input, '0'); + + await userEvent.type(input, '{Enter}'); + expect(onComplete).to.have.been.calledOnce(); + + await userEvent.click(submitButton); + expect(onComplete).to.have.been.calledTwice(); + }); }); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx index f9f5f76d6a8..43a5d9ae24d 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx @@ -1,4 +1,4 @@ -import { Alert, Button } from '@18f/identity-components'; +import { Button } from '@18f/identity-components'; import { FormStepsContext, FormStepsContinueButton } from '@18f/identity-form-steps'; import { t } from '@18f/identity-i18n'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; @@ -11,7 +11,8 @@ import type { VerifyFlowValues } from '../..'; interface PersonalKeyConfirmStepProps extends FormStepComponentProps {} function PersonalKeyConfirmStep(stepProps: PersonalKeyConfirmStepProps) { - const { registerField, errors, toPreviousStep } = stepProps; + const { registerField, value, onChange, toPreviousStep } = stepProps; + const personalKey = value.personalKey!; return ( <> @@ -24,25 +25,30 @@ function PersonalKeyConfirmStep(stepProps: PersonalKeyConfirmStepProps) { {t('forms.personal_key.title')} {t('forms.personal_key.instructions')} - {errors.length > 0 && ( - - {t('users.personal_key.confirmation_error')} - - )} - -
-
- + {/* Because the Modal renders into a portal outside the flow form, inputs would not normally + emit a submit event. We can reinstate the expected behavior with an empty form. A submit + event will bubble through the React portal boundary and be handled by FormSteps. Because + the form is not rendered in the same DOM hierarchy, it is not invalid nesting. */} + + onChange({ personalKeyConfirm })} + /> +
+
+ +
+
+ +
+
+ +
-
- -
-
- -
-
+ ); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx index 74369677bdb..70d63c24fb6 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.spec.tsx @@ -48,4 +48,18 @@ describe('PersonalKeyInput', () => { await userEvent.type(input, '12345'); expect(input.value).to.equal('1234-1234-1234-1234'); }); + + it('validates the input value against the expected value', async () => { + const { getByRole } = render(); + + const input = getByRole('textbox') as HTMLInputElement; + + await userEvent.type(input, '0000-0000-0000-000'); + input.checkValidity(); + expect(input.validationMessage).to.equal('users.personal_key.confirmation_error'); + + await userEvent.type(input, '0'); + input.checkValidity(); + expect(input.validationMessage).to.be.empty(); + }); }); diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx index 2970a35221b..478244e64bb 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-input.tsx @@ -1,22 +1,52 @@ -import { forwardRef } from 'react'; +import { forwardRef, useCallback } from 'react'; +import type { ForwardedRef } from 'react'; import Cleave from 'cleave.js/react'; import { t } from '@18f/identity-i18n'; +import { ValidatedField } from '@18f/identity-validated-field'; +import type { ValidatedFieldValidator } from '@18f/identity-validated-field'; + +interface PersonalKeyInputProps { + /** + * The correct personal key to validate against. + */ + expectedValue?: string; + + /** + * Callback invoked when the value of the input has changed. + */ + onChange?: (nextValue: string) => void; +} + +function PersonalKeyInput( + { expectedValue, onChange = () => {} }: PersonalKeyInputProps, + ref: ForwardedRef, +) { + const validate = useCallback( + (value) => { + if (expectedValue && value !== expectedValue) { + throw new Error(t('users.personal_key.confirmation_error')); + } + }, + [expectedValue], + ); -function PersonalKeyInput(_props, ref) { return ( - + + typeof ref === 'function' && ref(cleaveRef)} + aria-label={t('forms.personal_key.confirmation_label')} + autoComplete="off" + className="width-full field font-family-mono text-uppercase" + pattern="[a-zA-Z0-9-]+" + spellCheck={false} + type="text" + onInput={(event) => onChange((event.target as HTMLInputElement).value)} + /> + ); }