Skip to content
Open
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
53 changes: 50 additions & 3 deletions frontend/src/components/ui.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useState, useRef, type ReactNode } from "react";
import { handleFocusTrap, FOCUSABLE_SELECTOR } from "../lib/focus";

export function Icon({ name, className = "", fill = false }: { name: string; className?: string; fill?: boolean }) {
return (
Expand Down Expand Up @@ -60,20 +61,66 @@ export function Sheet({
children: ReactNode;
wide?: boolean;
}) {
const triggerRef = useRef<HTMLElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();

// Capture the trigger element before opening
triggerRef.current = document.activeElement as HTMLElement | null;

const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}

if (dialogRef.current) {
handleFocusTrap({
container: dialogRef.current,
event: e,
activeElement: document.activeElement,
});
}
};

window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";

// Focus into dialog on open
const timer = setTimeout(() => {
if (dialogRef.current) {
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusables.length > 0) {
focusables[0].focus();
} else {
dialogRef.current.focus();
}
}
}, 0);

return () => {
clearTimeout(timer);
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
// Restore focus to trigger element on close
triggerRef.current?.focus();
};
}, [open, onClose]);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-end justify-center md:items-center" role="dialog" aria-modal="true">
<div
ref={dialogRef}
className="fixed inset-0 z-50 flex items-end justify-center md:items-center"
role="dialog"
aria-modal="true"
tabIndex={-1}
>
<button aria-label="Close" className="absolute inset-0 bg-ink/30" onClick={onClose} />
<div
className={`floating relative max-h-[92vh] w-full overflow-y-auto rounded-t-lg bg-paper md:rounded-lg ${
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/lib/__tests__/focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import { handleFocusTrap } from "../focus";

describe("handleFocusTrap", () => {
function createMockContainer(elements: { tag: string; id: string; disabled?: boolean }[]) {
const container = {
contains: vi.fn((el) => elements.some((e) => e.id === el?.id)),
querySelectorAll: vi.fn((_selector: string) => {
return elements.map((item) => ({
id: item.id,
tagName: item.tag.toUpperCase(),
focus: vi.fn(),
getAttribute: vi.fn((attr: string) => (attr === "disabled" && item.disabled ? "" : null)),
style: {},
}));
}),
} as unknown as HTMLElement;
return container;
}

it("ignores non-Tab key events", () => {
const container = createMockContainer([{ tag: "button", id: "btn1" }]);
const preventDefault = vi.fn();
const handled = handleFocusTrap({
container,
event: { key: "Enter", shiftKey: false, preventDefault },
activeElement: null,
});

expect(handled).toBe(false);
expect(preventDefault).not.toHaveBeenCalled();
});

it("wraps from last to first on Tab", () => {
const elements = [
{ id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
{ id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
];
const container = {
contains: vi.fn(() => true),
querySelectorAll: vi.fn(() => elements),
} as unknown as HTMLElement;

const preventDefault = vi.fn();
const handled = handleFocusTrap({
container,
event: { key: "Tab", shiftKey: false, preventDefault },
activeElement: elements[1] as any, // Last element
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(elements[0].focus).toHaveBeenCalled();
});

it("wraps from first to last on Shift+Tab", () => {
const elements = [
{ id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
{ id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
];
const container = {
contains: vi.fn(() => true),
querySelectorAll: vi.fn(() => elements),
} as unknown as HTMLElement;

const preventDefault = vi.fn();
const handled = handleFocusTrap({
container,
event: { key: "Tab", shiftKey: true, preventDefault },
activeElement: elements[0] as any, // First element
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(elements[1].focus).toHaveBeenCalled();
});

it("focuses first element if focus is outside container on Tab", () => {
const elements = [
{ id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
{ id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
];
const container = {
contains: vi.fn(() => false),
querySelectorAll: vi.fn(() => elements),
} as unknown as HTMLElement;

const preventDefault = vi.fn();
const handled = handleFocusTrap({
container,
event: { key: "Tab", shiftKey: false, preventDefault },
activeElement: { id: "outside" } as any,
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(elements[0].focus).toHaveBeenCalled();
});

it("prevents default if no focusable elements are present", () => {
const container = {
contains: vi.fn(() => true),
querySelectorAll: vi.fn(() => []),
} as unknown as HTMLElement;

const preventDefault = vi.fn();
const handled = handleFocusTrap({
container,
event: { key: "Tab", shiftKey: false, preventDefault },
activeElement: null,
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
});
});
Loading