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
124 changes: 77 additions & 47 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions context/ui-interaction-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ shortcuts while it is open.
their `when` predicate no longer matches the active modal state.
- Outside-click, focus-leave, and Escape close paths should converge on the same
cleanup so stale frames, listeners, and highlighted rows are not left behind.
- Custom focus traps must cycle controls in rendered DOM order. If the trap
builds the focusable list from a mixed selector list (`button, input, select,
...`), normalize the result by document position before wrapping Tab /
Shift+Tab so selector-engine grouping cannot change keyboard order.

## Palette Persistence

Expand Down
6 changes: 3 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"dompurify": "3.4.11",
"ghostty-web": "^0.4.0",
"js-toml": "^1.0.3",
"marked": "17.0.6",
"marked": "18.0.5",
"mermaid": "11.15.0",
"openapi-fetch": "^0.17.0",
"simple-icons": "^16.19.0"
Expand All @@ -45,9 +45,9 @@
"@sveltejs/vite-plugin-svelte": "7.1.2",
"@testing-library/svelte": "5.4.0",
"@tsconfig/svelte": "5.0.8",
"@types/node": "^24.9.2",
"@types/node": "^26.0.0",
"@vitest/browser-playwright": "4.1.9",
"jsdom": "26.1.0",
"jsdom": "29.1.1",
"openapi-typescript": "^7.13.0",
"playwright": "1.61.0",
"svelte": "5.56.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import { render } from "vitest-browser-svelte";

import "../../../app.css";
import { pressKey } from "../../../test/browserAppHarness.js";
import RepoImportModal from "./RepoImportModal.svelte";

function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
expect(element).not.toBeNull();
return element!;
}

function controlByLabel<T extends HTMLElement>(labelText: string, selector: string): T {
const label = Array.from(document.querySelectorAll("label")).find((candidate) =>
candidate.textContent?.includes(labelText),
);
expect(label).not.toBeUndefined();
const control = label!.querySelector<T>(selector);
expect(control).not.toBeNull();
return control!;
}

describe("RepoImportModal focus trap (browser)", () => {
it("cycles Tab and Shift+Tab through controls in rendered order", async () => {
render(RepoImportModal, {
props: { open: true, onClose: vi.fn(), onImported: vi.fn() },
});

await expect.element(page.getByRole("dialog", { name: "Add repositories" })).toBeVisible();

const close = requireElement<HTMLButtonElement>("button[aria-label='Close']");
const provider = controlByLabel<HTMLSelectElement>("Provider", "select");
const host = controlByLabel<HTMLInputElement>("Host", "input");
const pattern = controlByLabel<HTMLInputElement>("Repository pattern", "input");
const cancel = page.getByRole("button", { name: "Cancel" }).element() as HTMLButtonElement;

await vi.waitFor(() => expect(document.activeElement).toBe(pattern));

pressKey("Tab", { shift: true }, pattern);
expect(document.activeElement).toBe(host);

pressKey("Tab", { shift: true }, host);
expect(document.activeElement).toBe(provider);

pressKey("Tab", { shift: true }, provider);
expect(document.activeElement).toBe(close);

pressKey("Tab", { shift: true }, close);
expect(document.activeElement).toBe(cancel);

pressKey("Tab", {}, cancel);
expect(document.activeElement).toBe(close);
});
});
26 changes: 15 additions & 11 deletions frontend/src/lib/components/settings/RepoImportModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@
if (!submitting) onClose();
}

function compareDocumentOrder(left: HTMLElement, right: HTMLElement): number {
if (left === right) return 0;
const position = left.compareDocumentPosition(right);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
}

function handleKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") {
closeIfAllowed();
Expand All @@ -193,18 +201,14 @@
modal.querySelectorAll<HTMLElement>(
"button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex='-1'])",
),
);
).sort(compareDocumentOrder);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
const currentIndex = focusable.findIndex((el) => el === document.activeElement);
const nextIndex = event.shiftKey
? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1)
: (currentIndex < 0 || currentIndex === focusable.length - 1 ? 0 : currentIndex + 1);
focusable[nextIndex]?.focus();
event.preventDefault();
}
</script>

Expand Down
2 changes: 1 addition & 1 deletion packages/github-app-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@playwright/test": "1.61.0",
"@sveltejs/vite-plugin-svelte": "7.1.2",
"@tsconfig/svelte": "5.0.8",
"@types/node": "^24.9.2",
"@types/node": "^26.0.0",
"playwright": "1.61.0",
"svelte": "5.56.3",
"svelte-check": "4.6.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,16 @@
"@tiptap/pm": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"dompurify": "3.4.11",
"marked": "17.0.6",
"marked": "18.0.5",
"openapi-fetch": "^0.17.0",
"prosemirror-state": "^1.4.4",
"shiki": "3.23.0",
"shiki": "4.2.0",
"svelte-tiptap": "3.0.1"
},
"devDependencies": {
"@testing-library/svelte": "5.4.0",
"@tsconfig/svelte": "^5.0.0",
"@types/node": "^24.9.2",
"@types/node": "^26.0.0",
"openapi-fetch": "^0.17.0",
"svelte": "^5.55.7",
"vite-plus": "0.2.1"
Expand Down
Loading