MAESTRO: improve accessibility focus and file picker

This commit is contained in:
Mariusz Banach
2026-02-18 04:47:43 +01:00
parent ffce9053a8
commit cfb945e1c4
10 changed files with 138 additions and 37 deletions

View File

@@ -128,4 +128,49 @@ describe("FileDropZone", () => {
expect(alert?.textContent ?? "").toMatch(/\.eml/i);
expect(alert?.textContent ?? "").toMatch(/\.txt/i);
});
it("opens the file picker on keyboard activation", () => {
const { container } = render(<FileDropZone onFileContent={() => undefined} />);
const dropZone = getDropZone(container);
const input = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) {
return;
}
const clickSpy = vi.spyOn(input, "click");
act(() => {
dropZone.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it("reads selected file content from the file picker", () => {
const handleContent = vi.fn();
const restore = mockFileReader("Header from input");
const { container } = render(<FileDropZone onFileContent={handleContent} />);
const input = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) {
restore();
return;
}
const file = new File(["Header from input"], "sample.eml", { type: "message/rfc822" });
Object.defineProperty(input, "files", {
value: [file],
writable: false,
});
act(() => {
input.dispatchEvent(new Event("change", { bubbles: true }));
});
restore();
expect(handleContent).toHaveBeenCalledWith("Header from input");
});
});

View File

@@ -34,6 +34,7 @@ export default function CaptchaChallenge({
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const titleId = useId();
const descriptionId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const answerRef = useRef("");
const modalRef = useRef<HTMLDivElement | null>(null);
@@ -151,6 +152,7 @@ export default function CaptchaChallenge({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
data-testid="captcha-challenge"
onKeyDown={handleKeyDown}
tabIndex={-1}
@@ -165,7 +167,9 @@ export default function CaptchaChallenge({
<h2 id={titleId} className="text-sm font-semibold text-text">
Security Check Required
</h2>
<p className="text-xs text-text/60">Solve the CAPTCHA to continue analysis.</p>
<p id={descriptionId} className="text-xs text-text/60">
Solve the CAPTCHA to continue analysis.
</p>
</div>
</div>
<button
@@ -206,7 +210,7 @@ export default function CaptchaChallenge({
</label>
{error ? (
<p className="text-xs text-spam" data-testid="captcha-error">
<p role="alert" className="text-xs text-spam" data-testid="captcha-error">
{error}
</p>
) : null}

View File

@@ -1,6 +1,13 @@
"use client";
import { type DragEventHandler, useState } from "react";
import {
type ChangeEvent,
type DragEventHandler,
type KeyboardEvent,
useId,
useRef,
useState,
} from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowUpFromBracket } from "@fortawesome/free-solid-svg-icons";
@@ -54,6 +61,32 @@ const getFirstFile = (transfer: DataTransfer | null): File | null => {
export default function FileDropZone({ onFileContent }: FileDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const helperTextId = useId();
const errorTextId = useId();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleFileSelection = (file: File) => {
if (!isSupportedFile(file)) {
setError("Only .eml or .txt files are supported.");
return;
}
if (file.size > MAX_FILE_BYTES) {
setError("File exceeds the 1 MB limit.");
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const content = typeof result === "string" ? result : "";
onFileContent(content);
};
reader.onerror = () => {
setError("Unable to read the dropped file.");
};
reader.readAsText(file);
};
const handleDragOver: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
@@ -82,53 +115,72 @@ export default function FileDropZone({ onFileContent }: FileDropZoneProps) {
return;
}
if (!isSupportedFile(file)) {
setError("Only .eml or .txt files are supported.");
return;
}
handleFileSelection(file);
};
if (file.size > MAX_FILE_BYTES) {
setError("File exceeds the 1 MB limit.");
return;
}
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const content = typeof result === "string" ? result : "";
onFileContent(content);
};
reader.onerror = () => {
setError("Unable to read the dropped file.");
};
reader.readAsText(file);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " " || event.key === "Spacebar") {
event.preventDefault();
handleSelectFile();
}
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const [file] = Array.from(event.currentTarget.files ?? []);
if (file) {
if (error) {
setError(null);
}
handleFileSelection(file);
}
event.currentTarget.value = "";
};
const borderClass = error ? "border-spam/70" : isDragging ? "border-info" : "border-info/40";
const surfaceClass = isDragging ? "bg-surface" : "bg-surface/70";
const describedBy = `${helperTextId}${error ? ` ${errorTextId}` : ""}`;
return (
<section className="flex flex-col gap-3">
<input
ref={fileInputRef}
type="file"
accept=".eml,.txt,message/rfc822,text/plain"
tabIndex={-1}
onChange={handleInputChange}
className="sr-only"
aria-hidden="true"
/>
<div
className={`rounded-2xl border border-dashed ${borderClass} ${surfaceClass} p-6 text-center transition-colors`}
className={`cursor-pointer rounded-2xl border border-dashed ${borderClass} ${surfaceClass} p-6 text-center transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info`}
data-testid="file-drop-zone"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleSelectFile}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-label="Drop an EML or TXT file"
aria-label="Drop or select an EML or TXT file"
aria-describedby={describedBy}
aria-invalid={error ? "true" : undefined}
>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full border border-info/30 bg-background/40">
<FontAwesomeIcon icon={faArrowUpFromBracket} className="text-sm text-info" />
</div>
<p className="mt-4 text-sm text-text/80">
Drop an EML or TXT file to auto-populate the header field.
Drop or click to choose an EML or TXT file to auto-populate the header field.
</p>
<p id={helperTextId} className="mt-2 font-mono text-xs text-text/50">
Max size 1MB
</p>
<p className="mt-2 font-mono text-xs text-text/50">Max size 1MB</p>
</div>
{error ? (
<p role="alert" className="text-xs text-spam">
<p role="alert" id={errorTextId} className="text-xs text-spam">
{error}
</p>
) : null}

View File

@@ -135,7 +135,7 @@ export default function ProgressIndicator({
</div>
<div className="flex items-center gap-4 text-xs text-text/60">
<span data-testid="progress-elapsed">{formatSeconds(elapsedSeconds)}</span>
<span className="text-text/30">/</span>
<span className="text-text/60">/</span>
<span data-testid="progress-remaining">{formatSeconds(remainingSeconds)}</span>
</div>
</div>

View File

@@ -137,17 +137,17 @@ export default function TestSelector({ selectedTestIds, onSelectionChange }: Tes
data-testid="test-selector"
>
<details open className="group">
<summary className="flex cursor-pointer list-none items-center justify-between text-xs uppercase tracking-[0.2em] text-info/90">
<summary className="flex cursor-pointer list-none items-center justify-between rounded-lg text-xs uppercase tracking-[0.2em] text-info/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info">
<span>Test Selection</span>
<span className="flex items-center gap-2 font-mono text-[10px] text-text/50">
{visibleCount} / {totalCount}
<FontAwesomeIcon icon={faChevronDown} className="text-[10px] text-text/40" />
<FontAwesomeIcon icon={faChevronDown} className="text-[10px] text-text/60" />
</span>
</summary>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<label className="flex flex-1 items-center gap-2 rounded-full border border-info/20 bg-background/40 px-3 py-2 text-sm text-text/70 focus-within:border-info/40">
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs text-text/40" />
<label className="flex flex-1 items-center gap-2 rounded-full border border-info/20 bg-background/40 px-3 py-2 text-sm text-text/70 focus-within:border-info/40 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-info">
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs text-text/60" />
<input
type="text"
value={searchText}

View File

@@ -79,7 +79,7 @@ export default function HopChainVisualisation({ hopChain }: HopChainVisualisatio
{index < hopChain.length - 1 ? (
<div
data-testid={`hop-chain-connector-${node.index}`}
className="ml-5 flex items-center gap-3 py-2 text-text/40"
className="ml-5 flex items-center gap-3 py-2 text-text/60"
>
<span className="h-8 w-px rounded-full bg-info/20" />
<FontAwesomeIcon icon={faArrowDown} className="text-xs" />

View File

@@ -129,7 +129,7 @@ export default function ReportContainer({ report }: { report: AnalysisReport })
<section className="rounded-2xl border border-info/10 bg-background/40 p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text/90">Summary Stats</h3>
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40">Totals</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-text/60">Totals</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (

View File

@@ -40,8 +40,8 @@ export default function ReportSearchBar({
className="rounded-2xl border border-info/10 bg-surface/50 p-4 shadow-[0_0_30px_rgba(15,23,42,0.18)]"
>
<div className="flex flex-wrap items-center gap-3">
<label className="flex flex-1 items-center gap-2 rounded-full border border-info/20 bg-background/40 px-3 py-2 text-sm text-text/70 focus-within:border-info/40">
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs text-text/40" />
<label className="flex flex-1 items-center gap-2 rounded-full border border-info/20 bg-background/40 px-3 py-2 text-sm text-text/70 focus-within:border-info/40 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-info">
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs text-text/60" />
<input
type="text"
value={query}

View File

@@ -135,7 +135,7 @@ export default function TestResultCard({ result, highlightQuery = "" }: TestResu
<div className="overflow-hidden">
<div className="rounded-xl border border-info/10 bg-background/40 p-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.2em] text-text/40">Header</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-text/60">Header</span>
<span className="text-xs text-text/60">
{highlightText(result.headerName, highlightQuery)}
</span>