Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions app/javascript/packages/document-capture/components/file-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>) {
const {
label,
Expand Down Expand Up @@ -214,6 +247,8 @@ function FileInput(props: FileInputProps, ref: ForwardedRef<any>) {
}, [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;
Expand Down Expand Up @@ -290,6 +325,15 @@ function FileInput(props: FileInputProps, ref: ForwardedRef<any>) {
successMessage = fileLoadedText;
}

const ariaDescribedby = getAriaDescribedby({
hint,
hintId,
shownErrorMessage,
errorId,
successMessage,
successId,
});

return (
<div
className={[
Expand Down Expand Up @@ -323,8 +367,11 @@ function FileInput(props: FileInputProps, ref: ForwardedRef<any>) {
{hint}
</span>
)}
<StatusMessage status={Status.ERROR}>{shownErrorMessage}</StatusMessage>
<StatusMessage status={Status.ERROR} id={errorId}>
{shownErrorMessage}
</StatusMessage>
<StatusMessage
id={successId}
status={Status.SUCCESS}
className={
successMessage === fileLoadingText || successMessage === fileLoadedText
Expand Down Expand Up @@ -398,7 +445,7 @@ function FileInput(props: FileInputProps, ref: ForwardedRef<any>) {
onClick={onClick}
onDrop={onDrop}
accept={accept ? accept.join() : undefined}
aria-describedby={hint ? hintId : undefined}
aria-describedby={ariaDescribedby}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -24,7 +25,7 @@ function StatusMessage({ status, className, children }: StatusMessageProps) {
const role = status === Status.ERROR ? 'alert' : 'status';

return (
<span role={role} className={classes}>
<span role={role} className={classes} id={id}>
{children}
</span>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FileInput label="File" />);
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(<FileInput label="File" hint="Must be small" />);

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(<FileInput {...props} />);

// 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(<FileInput {...props} />);

// The success message doesn't appear until the second file is uploaded successfully (AKA updated)
rerender(<FileInput {...props} value={file} />);
expect(() => getByText('File updated')).to.throw();

// Mock uploading the second file successfully
rerender(<FileInput {...props} value={file2} />);
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(<FileInput {...props} />);

// 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(<FileInput {...props} />);

// The success message doesn't appear until the second file is uploaded successfully (AKA updated)
rerender(<FileInput {...props} value={file} />);
expect(() => getByText('File updated')).to.throw();

// Mock uploading the second file successfully
rerender(<FileInput {...props} value={file2} />);
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(<FileInput label="File" onClick={onClick} />);
Expand Down