Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
384 changes: 384 additions & 0 deletions src/profiles/web/dist/assets/index-BRlfnag_.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/profiles/web/dist/assets/index-CcQFM_iR.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/profiles/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ProFiles</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/assets/index-QRvtM2rd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NlIXOAvu.css">
<script type="module" crossorigin src="/assets/index-BRlfnag_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CcQFM_iR.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body class="antialiased selection:bg-indigo-500 selection:text-white">
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion src/profiles/web/dist/sw.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/profiles/web/src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
@import "tailwindcss";

/* Baseline keyboard focus indicator: every interactive element shows a
visible outline on keyboard focus, even raw buttons that carry no
explicit focus utilities (WCAG 2.4.7). :where() keeps specificity at
zero, so elements that already handle focus locally — the .btn /
.focus-ring / focus-visible:ring utilities, which set
focus-visible:outline-none — keep their ring and suppress this one.
Outline follows border radius in modern browsers. */
:where(button, [role="button"], input, select, textarea, a, summary, video[tabindex], [tabindex]):focus-visible {
outline: 2px solid var(--color-indigo-500, #6366f1);
outline-offset: 2px;
}

:root {
color-scheme: dark light;
}
Expand Down
109 changes: 109 additions & 0 deletions src/profiles/web/src/utils/useModalA11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { useRef } from "react";
import { useModalA11y } from "./useModalA11y";

/** Minimal dialog wired through the hook, with an app button "behind" it. */
function Fixture({ onClose }: { onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useModalA11y({ isOpen: true, onClose, dialogRef });
return (
<div>
<button type="button" id="behind-app">
behind-app-button
</button>
<div ref={dialogRef} role="dialog" aria-modal="true" onKeyDown={handleDialogKeyDown}>
<button type="button" id="first">
first
</button>
<input id="middle" aria-label="middle" />
<button type="button" id="last">
last
</button>
</div>
</div>
);
}

function pressTab(shift = false): void {
document.body.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true, shiftKey: shift }),
);
}

afterEach(() => {
cleanup();
document.body.innerHTML = "";
});

describe("useModalA11y focus trap", () => {
it("pulls focus into the dialog when Tab is pressed while focus is on body", () => {
render(<Fixture onClose={vi.fn()} />);
screen.getByText("behind-app-button");
(document.activeElement as HTMLElement | null)?.blur?.();
expect(document.activeElement).toBe(document.body);

pressTab();

// Focus lands on the first control INSIDE the dialog — never on the
// app behind the modal.
expect(document.activeElement?.id).toBe("first");
});

it("Shift+Tab from body pulls focus to the last control", () => {
render(<Fixture onClose={vi.fn()} />);
screen.getByText("behind-app-button");
(document.activeElement as HTMLElement | null)?.blur?.();

pressTab(true);

expect(document.activeElement?.id).toBe("last");
});

it("wraps forward from the last focusable back to the first", () => {
render(<Fixture onClose={vi.fn()} />);
screen.getByText("behind-app-button");
(document.getElementById("last") as HTMLElement).focus();

pressTab();

expect(document.activeElement?.id).toBe("first");
});

it("wraps backward with Shift+Tab from the first focusable", () => {
render(<Fixture onClose={vi.fn()} />);
screen.getByText("behind-app-button");
(document.getElementById("first") as HTMLElement).focus();

pressTab(true);

expect(document.activeElement?.id).toBe("last");
});

it("keeps focus inside the dialog across repeated Tabs", () => {
render(<Fixture onClose={vi.fn()} />);
screen.getByText("behind-app-button");
(document.activeElement as HTMLElement | null)?.blur?.();

pressTab();
pressTab(); // last wrap edge (first → last)
pressTab();

const dialog = screen.getByRole("dialog");
expect(dialog.contains(document.activeElement)).toBe(true);
});

it("Escape still closes when focus is inside the dialog", () => {
const onClose = vi.fn();
render(<Fixture onClose={onClose} />);
screen.getByText("behind-app-button");
(document.getElementById("first") as HTMLElement).focus();

document.getElementById("first")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);

expect(onClose).toHaveBeenCalledTimes(1);
});
});
64 changes: 45 additions & 19 deletions src/profiles/web/src/utils/useModalA11y.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import type { KeyboardEvent, RefObject } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react";

const FOCUSABLE_SELECTOR =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], video[tabindex], [tabindex]:not([tabindex="-1"])';
Expand All @@ -17,14 +17,20 @@ export interface UseModalA11yOptions {
/**
* Shared dialog a11y: focus trap, initial focus and focus restore
* (issue #62). Attach the returned handler to the dialog element's
* `onKeyDown` so Escape and Tab are handled while focus is inside.
* `onKeyDown` so Escape is handled while focus is inside.
*
* The Tab half of the trap deliberately runs at the window capture level
* (see the effect), not in this handler: a dialog-level onKeyDown never
* sees a Tab pressed while focus sits on document.body — e.g. after a
* backdrop click or an unmounted focused node — and the browser would
* then happily move focus into the app behind the modal.
*/
export function useModalA11y({
isOpen,
onClose,
dialogRef,
initialFocusRef,
}: UseModalA11yOptions): (event: KeyboardEvent<HTMLElement>) => void {
}: UseModalA11yOptions): (event: ReactKeyboardEvent<HTMLElement>) => void {
useEffect(() => {
if (!isOpen) return;

Expand All @@ -39,34 +45,54 @@ export function useModalA11y({
target?.focus();
});

// Window-capture Tab trap: owns every Tab press while the modal is
// open, wherever focus currently sits. One listener per open modal;
// for stacked dialogs the most recently opened modal's handler runs
// last and wins, which resolves nesting in favor of the topmost one.
const trapTab = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
const inside = active instanceof Node && dialog.contains(active);

if (!inside) {
// Focus drifted out: pull it back instead of traversing the app
// behind the modal.
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (event.shiftKey && (active === first || active === dialog)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || active === dialog)) {
event.preventDefault();
first.focus();
}
// Anywhere else inside: leave Tab to native traversal between the
// dialog's own controls.
};
window.addEventListener("keydown", trapTab, true);

return () => {
cancelAnimationFrame(frame);
previousFocus?.focus();
window.removeEventListener("keydown", trapTab, true);
};
// Re-run when isOpen flips; refs are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);

return (event: KeyboardEvent<HTMLElement>) => {
return (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (event.key !== "Tab") return;

const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (!focusable?.length) return;

const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
}
2 changes: 1 addition & 1 deletion src/profiles/web/tsconfig.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/types.ts","./src/components/actionbar.tsx","./src/components/barcodemodal.tsx","./src/components/camerabarcodemodal.tsx","./src/components/confirmdialog.tsx","./src/components/contextmenu.tsx","./src/components/directorypickermodal.tsx","./src/components/dropconfigoverlay.tsx","./src/components/editablecombobox.tsx","./src/components/errorboundary.tsx","./src/components/filelisttable.tsx","./src/components/filepropertiesmodal.tsx","./src/components/filtersyntaxhelper.tsx","./src/components/headerbar.tsx","./src/components/initerrorpanel.tsx","./src/components/loadconfigmodal.tsx","./src/components/logviewermodal.tsx","./src/components/markdownmessage.test.tsx","./src/components/markdownmessage.tsx","./src/components/scanstatusstrip.tsx","./src/components/searchbar.tsx","./src/components/shortcutsmodal.tsx","./src/components/singlematchpromptmodal.tsx","./src/components/statusbar.tsx","./src/components/timermodal.tsx","./src/components/tooltip.tsx","./src/components/workflowrunnermodal.test.tsx","./src/components/workflowrunnermodal.tsx","./src/utils/barcodedecoder.test.ts","./src/utils/barcodedecoder.ts","./src/utils/barcodescanner.ts","./src/utils/configdrop.test.ts","./src/utils/configdrop.ts","./src/utils/exportcsv.ts","./src/utils/fileproperties.test.ts","./src/utils/fileproperties.ts","./src/utils/searchsyntax.test.ts","./src/utils/searchsyntax.ts","./src/utils/theme.test.ts","./src/utils/theme.ts","./src/utils/usemodala11y.ts"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/types.ts","./src/components/actionbar.tsx","./src/components/barcodemodal.tsx","./src/components/camerabarcodemodal.tsx","./src/components/confirmdialog.tsx","./src/components/contextmenu.tsx","./src/components/directorypickermodal.tsx","./src/components/dropconfigoverlay.tsx","./src/components/editablecombobox.tsx","./src/components/errorboundary.tsx","./src/components/filelisttable.tsx","./src/components/filepropertiesmodal.tsx","./src/components/filtersyntaxhelper.tsx","./src/components/headerbar.tsx","./src/components/initerrorpanel.tsx","./src/components/loadconfigmodal.tsx","./src/components/logviewermodal.tsx","./src/components/markdownmessage.test.tsx","./src/components/markdownmessage.tsx","./src/components/scanstatusstrip.tsx","./src/components/searchbar.tsx","./src/components/shortcutsmodal.tsx","./src/components/singlematchpromptmodal.tsx","./src/components/statusbar.tsx","./src/components/timermodal.tsx","./src/components/tooltip.tsx","./src/components/workflowrunnermodal.test.tsx","./src/components/workflowrunnermodal.tsx","./src/utils/barcodedecoder.test.ts","./src/utils/barcodedecoder.ts","./src/utils/barcodescanner.ts","./src/utils/configdrop.test.ts","./src/utils/configdrop.ts","./src/utils/exportcsv.ts","./src/utils/fileproperties.test.ts","./src/utils/fileproperties.ts","./src/utils/searchsyntax.test.ts","./src/utils/searchsyntax.ts","./src/utils/theme.test.ts","./src/utils/theme.ts","./src/utils/usemodala11y.test.tsx","./src/utils/usemodala11y.ts"],"version":"5.7.3"}
Loading