diff --git a/docs/redesign/phase-5/p5-15/p5-15-04/file-picker/README.md b/docs/redesign/phase-5/p5-15/p5-15-04/file-picker/README.md new file mode 100644 index 0000000..b248e2e --- /dev/null +++ b/docs/redesign/phase-5/p5-15/p5-15-04/file-picker/README.md @@ -0,0 +1,11 @@ +# P5-15-04-A11Y-01 — Native file-picker keyboard repair + +## Reproduction checkpoint + +From `f55c761ac229a69357a1c5b68a06e837cfa31ec0`, the unchanged native audit again reports six Enter/Space failures for Upload, Import JSON and Convert CSV to Import JSON; the three pointer comparisons succeed. The native suite's other failure is the separately owned CV source route. No files were uploaded by these chooser observations. + +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. + +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. diff --git a/job-tracker-ui/src/file-picker-activation.test.tsx b/job-tracker-ui/src/file-picker-activation.test.tsx new file mode 100644 index 0000000..26bf270 --- /dev/null +++ b/job-tracker-ui/src/file-picker-activation.test.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Attachments from "./components/Attachments"; +import ImportExportJobs from "./components/ImportExportJobs"; +import AddJobModal from "./components/AddJobModal"; +import { ConfirmProvider } from "./confirm"; +import { PromptProvider } from "./prompt"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ToastProvider } from "./toast"; +import { api } from "./api"; + +// These tests exercise file selection; the unrelated date-picker adapter is ESM. +jest.mock("@mui/x-date-pickers/DatePicker", () => ({ DatePicker: ({ label }: { label: string }) =>
{label}
})); + +const mockedApi = api as jest.Mocked; + +function renderPicker(component: React.ReactNode) { + return render({component}); +} + +async function expectNativeInputActivation(button: HTMLElement, input: HTMLInputElement) { + const activated = jest.fn(); + input.addEventListener("click", activated); + act(() => button.focus()); + await userEvent.keyboard("{Enter}"); + expect(activated).toHaveBeenCalledTimes(1); + await userEvent.keyboard(" "); + expect(activated).toHaveBeenCalledTimes(2); + userEvent.click(button); + expect(activated).toHaveBeenCalledTimes(3); + expect(button).toHaveFocus(); +} + +beforeEach(() => { + window.localStorage.clear(); + jest.clearAllMocks(); +}); + +test("attachment Upload activates the same native input from Enter, Space and pointer", async () => { + mockedApi.get.mockResolvedValue({ data: [] }); + const { container } = renderPicker(); + await screen.findByText(/No attachments yet\./); + await expectNativeInputActivation(screen.getByRole("button", { name: "Upload" }), container.querySelector('input[type="file"]')!); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test.each([ + ["Import JSON", "application/json"], + ["Convert CSV to Import JSON", ".csv,text/csv"], +])("%s activates its own native input from Enter, Space and pointer", async (name, accept) => { + const { container } = renderPicker(); + await expectNativeInputActivation(screen.getByRole("button", { name }), container.querySelector(`input[accept="${accept}"]`)!); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test.each([0, 1, 2, 3])("Add Job attachment bucket %s retains keyboard selection and distinct pending files", async (bucket) => { + mockedApi.post.mockImplementation((url) => Promise.resolve({ data: url === "/jobimport/preview" ? { success: true, title: "Supported role", company: "Fixture company", description: "Known description" } : {} })); + renderPicker( {}} onCreated={() => {}} />); + await screen.findByDisplayValue("Supported role"); + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + for (let step = 0; step < bucket; step += 1) fireEvent.click(screen.getByRole("button", { name: "Skip and continue" })); + const input = document.querySelector('input[type="file"]')!; + expect(input.multiple).toBe(true); + expect(input.accept).toContain(".pdf,.doc,.docx,.txt,.md"); + await expectNativeInputActivation(screen.getByRole("button", { name: "Choose files" }), input); + fireEvent.change(input, { target: { files: [new File(["fixture"], `bucket-${bucket}.txt`, { type: "text/plain" })] } }); + expect(screen.getByText(`bucket-${bucket}.txt`)).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalledWith("/attachments", expect.anything(), expect.anything()); +}); + +test("attachment selection retains files and job ownership, disables activation while uploading and clears selection after completion", async () => { + mockedApi.get.mockResolvedValue({ data: [] }); + let finishUpload!: (value: { data: object }) => void; + mockedApi.post.mockImplementationOnce(() => new Promise((resolve) => { finishUpload = resolve; })); + const { container } = renderPicker(); + await screen.findByText(/No attachments yet\./); + const input = container.querySelector('input[type="file"]')!; + const files = [new File(["Known CV"], "cv.pdf", { type: "application/pdf" }), new File(["Søknad"], "søknad.txt", { type: "text/plain" })]; + fireEvent.change(input, { target: { files } }); + const uploading = await screen.findByRole("button", { name: "Uploading..." }); + expect(uploading).toBeDisabled(); + const body = mockedApi.post.mock.calls[0][1] as FormData; + expect(body.get("jobId")).toBe("42"); + expect(body.getAll("files")).toEqual(files); + const activated = jest.fn(); + input.addEventListener("click", activated); + userEvent.click(uploading); + expect(activated).not.toHaveBeenCalled(); + finishUpload({ data: {} }); + await waitFor(() => expect(screen.getByRole("button", { name: "Upload" })).toBeEnabled()); + expect(input.value).toBe(""); +});