fix(a11y): restore native keyboard file selection

This commit is contained in:
cesnimda
2026-10-10 21:52:01 +02:00
parent 24c98e160f
commit 57054349ef
5 changed files with 26 additions and 14 deletions
@@ -6,6 +6,14 @@ From `f55c761ac229a69357a1c5b68a06e837cfa31ec0`, the unchanged native audit agai
The label-only MUI buttons have no click handler. MUI keyboard activation invokes a nonnative button's handler; it does not emulate the browser's label-to-input default activation. The same pattern exists in Add Job's four attachment buckets. It is not an API, permissions or file-validation failure.
`src/file-picker-activation.test.tsx` adds eight real-component cases: seven keyboard/input activation boundaries (all fail with expected one input click/actual zero), plus file/Job association, uploading disabled state and post-upload selection reset. The latter also exposes that a label is not a native disabled button. Earlier test-fixture label/step mistakes were corrected before recording the final RED; they are not application defects.
`src/file-picker-activation.test.tsx` adds eight real-component cases: seven keyboard/input activation boundaries fail with expected one input click/actual zero; an eighth covers file/Job association, uploading disabled state and post-upload selection reset. Its initial matcher setup failed and is not application RED evidence. Fixture label/step and matcher setup mistakes were corrected; they are not application defects.
Affected contracts: PRES-W08 attachments and PRES-J10 import/export; Add Job same-pattern caller is covered without changing its domain workflow. Whole groups remain FAIL at this reproduction checkpoint. No implementation correction or fresh full-suite claim is made here. Production remains NO-GO; human screen-reader/device acceptance remains incomplete.
## Repair successor — GO for the bounded functional repair
The three audited controls and Add Job's shared bucket control now use native buttons that click their existing referenced file inputs. Inputs remain hidden siblings, preventing bubbled input clicks from reactivating the button. Existing accepted-file constraints, multiple selection, bucket ownership, API payloads, upload progress/reset and import/conversion implementations are retained. There is no API/schema/storage change or extra dependency.
Fresh rebuilt application: the original native chooser case passes **all six Enter/Space checks and three pointer checks**, without writing file data. The eight real-component cases pass, including distinct Add Job buckets and the two-file Job association/disabled-upload/reset behaviour. Complete frontend **498/498 across83 suites**, +8 from490; TypeScript, lint and production export build pass. Fresh backend1,033, focusedAI108, revision5/5 and sidecar49 also pass; those unchanged backend contracts remain independent owner/data proof. The suite's other documented browser failures are not claimed fixed by this repair.
PRES-W08/J10 may move FAIL→PARTIAL after this verified boundary is reconciled; whole-group human/provider/long-tail requirements remain. Actual native chooser activation is stronger than a mocked picker success; upload/domain ownership additionally has backend evidence. Human screen-reader and physical-touch acceptance are still incomplete. Production remains NO-GO.
@@ -144,6 +144,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
const [notes, setNotes] = useState("");
const [generateTailoredCv, setGenerateTailoredCv] = useState(false);
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
const attachmentInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setCompanies(cachedCompanies);
@@ -421,10 +422,10 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Typography sx={{ fontWeight: 800 }}>{label}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{helperText}</Typography>
</Box>
<Button component="label" variant="outlined" size="small" startIcon={<UploadFileOutlinedIcon />}>
<Button variant="outlined" size="small" startIcon={<UploadFileOutlinedIcon />} onClick={() => attachmentInputRef.current?.click()}>
{t("addJobModalChooseFiles")}
<input hidden type="file" multiple accept={ACCEPTED_DOCUMENT_TYPES} onChange={(e) => setFilesForBucket(bucket, e.target.files)} />
</Button>
<input ref={attachmentInputRef} hidden type="file" multiple accept={ACCEPTED_DOCUMENT_TYPES} onChange={(e) => setFilesForBucket(bucket, e.target.files)} />
</Box>
<Typography variant="caption" sx={{ display: "block", mt: 1, color: "text.secondary" }}>
{filesLabel(attachments[bucket])}
@@ -233,10 +233,10 @@ export default function Attachments({ jobId }: { jobId: number }) {
<Chip size="small" label={t("attachmentsMaxSize")} variant="outlined" />
</Box>
</Box>
<Button component="label" size="small" variant="outlined" startIcon={<CloudUploadOutlinedIcon />} disabled={uploading}>
<Button size="small" variant="outlined" startIcon={<CloudUploadOutlinedIcon />} disabled={uploading} onClick={() => fileInputRef.current?.click()}>
{uploading ? t("attachmentsUploading") : t("attachmentsUpload")}
<input ref={fileInputRef} type="file" multiple hidden onChange={upload} />
</Button>
<input ref={fileInputRef} type="file" multiple hidden onChange={upload} />
</Box>
<Box
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import { Box, Button, Typography } from "@mui/material";
import { PageSection } from "../layout/PageLayout";
@@ -180,6 +180,8 @@ export default function ImportExportJobs() {
const { t } = useI18n();
const [importing, setImporting] = useState(false);
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const csvInputRef = useRef<HTMLInputElement>(null);
const exportAll = async (format: "json" | "csv") => {
try {
@@ -255,15 +257,15 @@ export default function ImportExportJobs() {
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
<Button variant="contained" component="label" disabled={importing}>
<Button variant="contained" disabled={importing} onClick={() => importInputRef.current?.click()}>
{importing ? t("profileUploading") : t("importJson")}
<input type="file" accept="application/json" hidden onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)} />
</Button>
<input ref={importInputRef} type="file" accept="application/json" hidden onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)} />
<Button variant="outlined" component="label">
<Button variant="outlined" onClick={() => csvInputRef.current?.click()}>
{t("convertCsvToImportJson")}
<input type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
</Button>
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
{lastImportCount !== null && (
<Typography sx={{ ml: 1, color: "text.secondary" }}>
@@ -1,4 +1,5 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Attachments from "./components/Attachments";
@@ -23,11 +24,11 @@ async function expectNativeInputActivation(button: HTMLElement, input: HTMLInput
const activated = jest.fn();
input.addEventListener("click", activated);
act(() => button.focus());
await userEvent.keyboard("{Enter}");
await act(async () => { await userEvent.keyboard("{Enter}"); });
expect(activated).toHaveBeenCalledTimes(1);
await userEvent.keyboard(" ");
await act(async () => { await userEvent.keyboard(" "); });
expect(activated).toHaveBeenCalledTimes(2);
userEvent.click(button);
await act(async () => { userEvent.click(button); });
expect(activated).toHaveBeenCalledTimes(3);
expect(button).toHaveFocus();
}
@@ -85,7 +86,7 @@ test("attachment selection retains files and job ownership, disables activation
expect(body.getAll("files")).toEqual(files);
const activated = jest.fn();
input.addEventListener("click", activated);
userEvent.click(uploading);
act(() => uploading.click());
expect(activated).not.toHaveBeenCalled();
finishUpload({ data: {} });
await waitFor(() => expect(screen.getByRole("button", { name: "Upload" })).toBeEnabled());