diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 0b71c29398c..384d1a1c1b8 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -552,6 +552,10 @@ function AcuantCapture( captureAttempts, }); + if (fullScreenRef.current?.focusTrap) { + suspendFocusTrapForAnticipatedFocus(fullScreenRef.current.focusTrap); + } + // Internally, Acuant sets a cookie to bail on guided capture if initialization had // previously failed for any reason, including declined permission. Since the cookie // never expires, and since we want to re-prompt even if the user had previously diff --git a/app/javascript/packages/document-capture/components/file-input.tsx b/app/javascript/packages/document-capture/components/file-input.tsx index f5f66ae04c3..fcc2a0b02ec 100644 --- a/app/javascript/packages/document-capture/components/file-input.tsx +++ b/app/javascript/packages/document-capture/components/file-input.tsx @@ -163,6 +163,39 @@ export function isValidForAccepts(mimeType: string, accept?: string[]): boolean ); } +interface AriaDescribedbyArguments { + hint: string | undefined; + hintId: string; + shownErrorMessage: ReactNode | string | undefined; + errorId: string; + successMessage: string | undefined; + successId: string; +} +function getAriaDescribedby({ + hint, + hintId, + shownErrorMessage, + errorId, + successMessage, + successId, +}: AriaDescribedbyArguments) { + // Error and success messages can't appear together, but either + // error or success messages can appear with a hint message. + const errorMessageShown = !!shownErrorMessage; + const successMessageShown = !errorMessageShown && successMessage; + const optionalHintId = hint ? hintId : undefined; + + if (errorMessageShown) { + return optionalHintId ? `${errorId} ${optionalHintId}` : errorId; + } + if (successMessageShown) { + return optionalHintId ? `${successId} ${optionalHintId}` : successId; + } + // if (!errorMessageShown && !successMessageShown) is the intent, + // leaving it like this so it's also the default. + return optionalHintId; +} + function FileInput(props: FileInputProps, ref: ForwardedRef) { const { label, @@ -214,6 +247,8 @@ function FileInput(props: FileInputProps, ref: ForwardedRef) { }, [value]); const inputId = `file-input-${instanceId}`; const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + const successId = `${inputId}-success`; const innerHintId = `${hintId}-inner`; const labelId = `${inputId}-label`; const showInnerHint: boolean = !value && !isValuePending && !isMobile; @@ -290,6 +325,15 @@ function FileInput(props: FileInputProps, ref: ForwardedRef) { successMessage = fileLoadedText; } + const ariaDescribedby = getAriaDescribedby({ + hint, + hintId, + shownErrorMessage, + errorId, + successMessage, + successId, + }); + return (
) { {hint} )} - {shownErrorMessage} + + {shownErrorMessage} + ) { onClick={onClick} onDrop={onDrop} accept={accept ? accept.join() : undefined} - aria-describedby={hint ? hintId : undefined} + aria-describedby={ariaDescribedby} />
diff --git a/app/javascript/packages/document-capture/components/status-message.tsx b/app/javascript/packages/document-capture/components/status-message.tsx index 9b6518901e8..b7d6f8e82b0 100644 --- a/app/javascript/packages/document-capture/components/status-message.tsx +++ b/app/javascript/packages/document-capture/components/status-message.tsx @@ -6,12 +6,13 @@ export enum Status { } interface StatusMessageProps { + id: string; status: Status; className?: string; children?: ReactNode; } -function StatusMessage({ status, className, children }: StatusMessageProps) { +function StatusMessage({ id, status, className, children }: StatusMessageProps) { const classes = [ status === Status.ERROR && 'usa-error-message', status === Status.SUCCESS && 'usa-success-message', @@ -24,7 +25,7 @@ function StatusMessage({ status, className, children }: StatusMessageProps) { const role = status === Status.ERROR ? 'alert' : 'status'; return ( - + {children} ); diff --git a/spec/javascript/packages/document-capture/components/file-input-spec.jsx b/spec/javascript/packages/document-capture/components/file-input-spec.jsx index b2cb72d1a39..42db8489f86 100644 --- a/spec/javascript/packages/document-capture/components/file-input-spec.jsx +++ b/spec/javascript/packages/document-capture/components/file-input-spec.jsx @@ -268,6 +268,100 @@ describe('document-capture/components/file-input', () => { expect(queryByAriaLabel).to.exist(); }); + it('has aria-describedby set to null by default', () => { + const { getByLabelText } = render(); + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.null(); + }); + + it('has aria-describedby set to hintId when hint shown and neither success or error shown', () => { + const { getByLabelText, container } = render(); + + const labelElement = container.querySelector('.usa-hint'); + const hintId = labelElement.getAttribute('id'); + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.equal(hintId); + }); + + it('has aria-describedby set to errorId when only error message shown', () => { + const props = { fileUpdatedText: 'File updated', label: 'File', errorMessage: 'Oops!' }; + const { getByLabelText, container } = render(); + + // Extract the id of the error message + const errorMessageElement = container.querySelector('.usa-error-message'); + const errorId = errorMessageElement.getAttribute('id'); + + // Now check that the aria-describedby is what we expect + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.equal(errorId); + }); + + it('has aria-describedby set to successId when only success message shown', () => { + const file2 = new window.File([file], 'file2.jpg'); + const props = { fileUpdatedText: 'File updated', label: 'File' }; + const { getByText, getByLabelText, container, rerender } = render(); + + // The success message doesn't appear until the second file is uploaded successfully (AKA updated) + rerender(); + expect(() => getByText('File updated')).to.throw(); + + // Mock uploading the second file successfully + rerender(); + expect(getByText('File updated')).to.be.ok(); + + // Extract the id of the success message + const successMessageElement = container.querySelector('.usa-success-message'); + const successId = successMessageElement.getAttribute('id'); + + // Now check that the aria-describedby is what we expect + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.equal(successId); + }); + + it('has aria-describedby set to combination of errorId and hintId when hint and error shown', () => { + const props = { + fileUpdatedText: 'File updated', + label: 'File', + hint: 'Must be small', + errorMessage: 'Oops!', + }; + const { getByLabelText, container } = render(); + + // Extract the ids of the hint and error message + const labelElement = container.querySelector('.usa-hint'); + const hintId = labelElement.getAttribute('id'); + const errorMessageElement = container.querySelector('.usa-error-message'); + const errorId = errorMessageElement.getAttribute('id'); + + // Now check that the aria-describedby is what we expect + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.equal(`${errorId} ${hintId}`); + }); + + it('has aria-describedby set to combination of successId and hintId when hint and success shown', () => { + const file2 = new window.File([file], 'file2.jpg'); + const props = { fileUpdatedText: 'File updated', label: 'File', hint: 'Must be small' }; + const { getByText, getByLabelText, container, rerender } = render(); + + // The success message doesn't appear until the second file is uploaded successfully (AKA updated) + rerender(); + expect(() => getByText('File updated')).to.throw(); + + // Mock uploading the second file successfully + rerender(); + expect(getByText('File updated')).to.be.ok(); + + // Extract the ids of the hint and success message + const labelElement = container.querySelector('.usa-hint'); + const hintId = labelElement.getAttribute('id'); + const successMessageElement = container.querySelector('.usa-success-message'); + const successId = successMessageElement.getAttribute('id'); + + // Now check that the aria-describedby is what we expect + const input = getByLabelText('File'); + expect(input.getAttribute('aria-describedby')).to.be.equal(`${successId} ${hintId}`); + }); + it('calls onClick when clicked', async () => { const onClick = sinon.stub(); const { getByLabelText } = render();