From 047c053aff3971f68f19b228fe773d866b4c4f11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:02:50 +0000 Subject: [PATCH] Redesign example-picker tabs as DaisyUI boxed tabs The Code Judge Examples picker used `tabs tabs-bordered flex-nowrap overflow-x-auto` to keep five long labels on one line. DaisyUI's `.tab` is a fixed height, so the horizontal scrollbar ate into it: labels were sliced in half, the bordered underline hid behind the scrollbar track, and the active tab could scroll out of view. Both example pickers now render as boxed tabs that wrap instead of scrolling. DaisyUI v4 makes `.tabs` a grid with every `.tab` pinned to row 1, so the container carries explicit `flex flex-wrap` to get real row wrapping rather than labels wrapping inside fixed-height pills. Declaring the tabs roles means honoring the APG contract, so both pickers also get roving tabindex, ArrowLeft/ArrowRight with wrap-around, Home/End, focus following selection, and a focusable code panel so it can be scrolled without a pointer. Modified arrow keys pass through to the browser. The Code Tool picker carried the same markup and now gets its own route test, so the two copies fail a test if they drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JpYqNrEVKufmxd9QN9X1n3 --- .../eval_types/code_eval_form.svelte | 65 ++++++- .../eval_types/code_eval_form.test.ts | 100 ++++++++++ .../add_tools/code_tool/+page.svelte | 61 +++++- .../code_tool/__tests__/app_page_stub.svelte | 14 ++ .../code_tool/__tests__/dialog_stub.svelte | 13 ++ .../__tests__/passthrough_stub.svelte | 36 ++++ .../add_tools/code_tool/page.test.ts | 175 ++++++++++++++++++ 7 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/app_page_stub.svelte create mode 100644 app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/dialog_stub.svelte create mode 100644 app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/passthrough_stub.svelte create mode 100644 app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/page.test.ts diff --git a/app/web_ui/src/lib/components/eval_types/code_eval_form.svelte b/app/web_ui/src/lib/components/eval_types/code_eval_form.svelte index 2bdf551f17..395765d876 100644 --- a/app/web_ui/src/lib/components/eval_types/code_eval_form.svelte +++ b/app/web_ui/src/lib/components/eval_types/code_eval_form.svelte @@ -146,6 +146,43 @@ let examples_dialog: Dialog let active_example_tab: number = 0 + // Unique so the ids stay correct if two eval-type forms are ever rendered + // at once (this component is mounted via the eval type registry). + const example_tab_id_prefix = + "code_eval_example_" + Math.random().toString(36).slice(2) + + let example_tab_buttons: HTMLButtonElement[] = [] + + function focus_example_tab(index: number) { + active_example_tab = index + example_tab_buttons[index]?.focus() + } + + function on_example_tab_keydown(event: KeyboardEvent, index: number) { + // Leave browser/OS shortcuts alone (Alt+Left is Back on Windows/Linux). + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + const last = examples.length - 1 + switch (event.key) { + case "ArrowRight": + focus_example_tab(index === last ? 0 : index + 1) + break + case "ArrowLeft": + focus_example_tab(index === 0 ? last : index - 1) + break + case "Home": + focus_example_tab(0) + break + case "End": + focus_example_tab(last) + break + default: + return + } + event.preventDefault() + } + // Examples follow the same rule as the starter: the real score key once the // eval has a valid name, the placeholder before then. $: examples = generate_examples( @@ -260,20 +297,42 @@ ]} >
-
+ + +
{#each examples as example, i} {/each}
+
{examples[active_example_tab].code} diff --git a/app/web_ui/src/lib/components/eval_types/code_eval_form.test.ts b/app/web_ui/src/lib/components/eval_types/code_eval_form.test.ts index ea63568326..f0d8bcd8da 100644 --- a/app/web_ui/src/lib/components/eval_types/code_eval_form.test.ts +++ b/app/web_ui/src/lib/components/eval_types/code_eval_form.test.ts @@ -207,12 +207,112 @@ describe("CodeEvalForm", () => { const tabs = container.querySelectorAll(".tab") expect(tabs[0].classList.contains("tab-active")).toBe(true) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") expect(tabs[1].classList.contains("tab-active")).toBe(false) + expect(tabs[1].getAttribute("aria-selected")).toBe("false") await fireEvent.click(tabs[1]) expect(tabs[0].classList.contains("tab-active")).toBe(false) + expect(tabs[0].getAttribute("aria-selected")).toBe("false") expect(tabs[1].classList.contains("tab-active")).toBe(true) + expect(tabs[1].getAttribute("aria-selected")).toBe("true") + }) + + it("renders example tabs as a named, wrapping boxed tab group", () => { + const { container } = render(CodeEvalForm) + const tablist = container.querySelector('[role="tablist"]') + expect(tablist).not.toBeNull() + expect(tablist?.getAttribute("aria-label")).toBe("Examples") + expect(tablist?.classList.contains("tabs-boxed")).toBe(true) + expect(tablist?.classList.contains("flex-wrap")).toBe(true) + expect(container.querySelectorAll('[role="tab"]').length).toBe(5) + }) + + it("wraps each label so an over-long one ellipsizes", () => { + const { container } = render(CodeEvalForm) + const tabs = container.querySelectorAll('[role="tab"]') + // text-overflow is inert on .tab itself (a flex container), so the label + // must stay inside a block child for truncation to work at all + expect(tabs[0].querySelector("span")?.classList.contains("truncate")).toBe( + true, + ) + }) + + it("moves the active example tab with the arrow, home and end keys", async () => { + const { container } = render(CodeEvalForm) + const tabs = container.querySelectorAll('[role="tab"]') + + await fireEvent.keyDown(tabs[0], { key: "ArrowRight" }) + expect(tabs[1].getAttribute("aria-selected")).toBe("true") + expect(document.activeElement).toBe(tabs[1]) + + // Wraps around both ends so the group is a single loop + await fireEvent.keyDown(tabs[1], { key: "ArrowLeft" }) + await fireEvent.keyDown(tabs[0], { key: "ArrowLeft" }) + expect(tabs[4].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[4], { key: "ArrowRight" }) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[0], { key: "End" }) + expect(tabs[4].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[4], { key: "Home" }) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + }) + + it("leaves keys it does not handle to the browser", async () => { + const { container } = render(CodeEvalForm) + const tabs = container.querySelectorAll('[role="tab"]') + + // fireEvent returns false when the default was prevented + expect(await fireEvent.keyDown(tabs[0], { key: "ArrowDown" })).toBe(true) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + + // Alt+Left is browser Back, so the tab group must not swallow it + expect( + await fireEvent.keyDown(tabs[0], { key: "ArrowLeft", altKey: true }), + ).toBe(true) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + }) + + it("keeps only the active example tab in the tab sequence", async () => { + const { container } = render(CodeEvalForm) + const tabs = container.querySelectorAll('[role="tab"]') + const panel = container.querySelector('[role="tabpanel"]') + + expect(tabs[0].getAttribute("tabindex")).toBe("0") + expect(tabs[1].getAttribute("tabindex")).toBe("-1") + // The panel scrolls horizontally, so it needs to be keyboard reachable + expect(panel?.getAttribute("tabindex")).toBe("0") + + await fireEvent.click(tabs[1]) + + expect(tabs[0].getAttribute("tabindex")).toBe("-1") + expect(tabs[1].getAttribute("tabindex")).toBe("0") + }) + + it("labels the example code panel with the active tab", async () => { + const { container } = render(CodeEvalForm) + const panel = container.querySelector('[role="tabpanel"]') + const tabs = container.querySelectorAll('[role="tab"]') + expect(panel?.getAttribute("aria-labelledby")).toBe(tabs[0].id) + + await fireEvent.click(tabs[2]) + + expect(panel?.getAttribute("aria-labelledby")).toBe(tabs[2].id) + expect(tabs[2].getAttribute("aria-controls")).toBe(panel?.id) + }) + + it("scopes example tab ids per instance so two forms cannot collide", () => { + const first = render(CodeEvalForm).container + const second = render(CodeEvalForm).container + const first_tab = first.querySelector('[role="tab"]') + const second_tab = second.querySelector('[role="tab"]') + + expect(first_tab?.id).toBeTruthy() + expect(first_tab?.id).not.toBe(second_tab?.id) }) it("renders code editor stub with default code", () => { diff --git a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/+page.svelte b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/+page.svelte index 7a3a484e1d..8f0a29c025 100644 --- a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/+page.svelte +++ b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/+page.svelte @@ -90,6 +90,7 @@ let create_trust_dialog: CodeTrustDialog let examples_dialog: Dialog let active_example_tab = 0 + let example_tab_buttons: HTMLButtonElement[] = [] $: examples = generateExamples() @@ -218,6 +219,36 @@ examples_dialog.show() } + function focus_example_tab(index: number) { + active_example_tab = index + example_tab_buttons[index]?.focus() + } + + function on_example_tab_keydown(event: KeyboardEvent, index: number) { + // Leave browser/OS shortcuts alone (Alt+Left is Back on Windows/Linux). + if (event.altKey || event.ctrlKey || event.metaKey) { + return + } + const last = examples.length - 1 + switch (event.key) { + case "ArrowRight": + focus_example_tab(index === last ? 0 : index + 1) + break + case "ArrowLeft": + focus_example_tab(index === 0 ? last : index - 1) + break + case "Home": + focus_example_tab(0) + break + case "End": + focus_example_tab(last) + break + default: + return + } + event.preventDefault() + } + function use_example(): boolean { code = examples[active_example_tab].code code_editor?.setValue(code) @@ -522,18 +553,42 @@ ]} >
-
+ + +
{#each examples as example, i} {/each}
+
{examples[active_example_tab].code} diff --git a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/app_page_stub.svelte b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/app_page_stub.svelte new file mode 100644 index 0000000000..cec340e99d --- /dev/null +++ b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/app_page_stub.svelte @@ -0,0 +1,14 @@ + + +
+ +
diff --git a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/dialog_stub.svelte b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/dialog_stub.svelte new file mode 100644 index 0000000000..285e530664 --- /dev/null +++ b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/dialog_stub.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/passthrough_stub.svelte b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/passthrough_stub.svelte new file mode 100644 index 0000000000..0354603ae9 --- /dev/null +++ b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/__tests__/passthrough_stub.svelte @@ -0,0 +1,36 @@ + + +
diff --git a/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/page.test.ts b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/page.test.ts new file mode 100644 index 0000000000..c85d206fe0 --- /dev/null +++ b/app/web_ui/src/routes/(app)/tools/[project_id]/add_tools/code_tool/page.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest" +import { render, fireEvent, cleanup } from "@testing-library/svelte" + +const { mockPage } = vi.hoisted(() => { + const page_value = { + params: { project_id: "proj1" }, + // The examples dialog only exists on the code step of the wizard + state: { wizard_step: "code" }, + url: new URL("http://localhost/tools/proj1/add_tools/code_tool"), + } + return { + mockPage: { + subscribe(fn: (value: typeof page_value) => void) { + fn(page_value) + return () => {} + }, + }, + } +}) + +vi.mock("$app/stores", () => ({ page: mockPage })) + +vi.mock("$app/navigation", () => ({ + goto: vi.fn(), + pushState: vi.fn(), +})) + +vi.mock("$lib/api_client", () => ({ + client: { GET: vi.fn(), POST: vi.fn() }, +})) + +vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })) + +vi.mock("$lib/agent", () => ({ agentInfo: { set: vi.fn() } })) + +vi.mock("../../../../app_page.svelte", async () => { + const Stub = await import("./__tests__/app_page_stub.svelte") + return { default: Stub.default } +}) + +vi.mock("$lib/ui/dialog.svelte", async () => { + const Stub = await import("./__tests__/dialog_stub.svelte") + return { default: Stub.default } +}) + +vi.mock("$lib/components/code_editor.svelte", async () => { + const Stub = await import("./__tests__/passthrough_stub.svelte") + return { default: Stub.default } +}) + +vi.mock("$lib/components/code_tools/code_tool_test_panel.svelte", async () => { + const Stub = await import("./__tests__/passthrough_stub.svelte") + return { default: Stub.default } +}) + +vi.mock("$lib/ui/run_config_component/tools_selector.svelte", async () => { + const Stub = await import("./__tests__/passthrough_stub.svelte") + return { default: Stub.default } +}) + +const Page = (await import("./+page.svelte")).default + +afterEach(() => { + cleanup() +}) + +function render_example_tabs() { + const { container } = render(Page) + const tabs = container.querySelectorAll('[role="tab"]') + return { container, tabs } +} + +// The eval-type version of this picker lives in code_eval_form.svelte and is +// tested alongside it. Both are maintained in place, so both are pinned. +describe("Code Tool page — example picker", () => { + it("renders the examples as a named, wrapping boxed tab group", () => { + const { container, tabs } = render_example_tabs() + const tablist = container.querySelector('[role="tablist"]') + + expect(tablist?.getAttribute("aria-label")).toBe("Examples") + expect(tablist?.classList.contains("tabs-boxed")).toBe(true) + expect(tablist?.classList.contains("flex-wrap")).toBe(true) + expect(tabs.length).toBe(3) + expect(tabs[0].textContent?.trim()).toBe("Parallel with Retries") + }) + + it("wraps each label so an over-long one ellipsizes", () => { + const { tabs } = render_example_tabs() + // text-overflow is inert on .tab itself (a flex container), so the label + // must stay inside a block child for truncation to work at all + expect(tabs[0].querySelector("span")?.classList.contains("truncate")).toBe( + true, + ) + }) + + it("switches the active example tab on click", async () => { + const { tabs } = render_example_tabs() + + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + expect(tabs[0].classList.contains("tab-active")).toBe(true) + + await fireEvent.click(tabs[2]) + + expect(tabs[0].getAttribute("aria-selected")).toBe("false") + expect(tabs[2].getAttribute("aria-selected")).toBe("true") + expect(tabs[2].classList.contains("tab-active")).toBe(true) + }) + + it("moves the active example tab with the arrow, home and end keys", async () => { + const { tabs } = render_example_tabs() + + await fireEvent.keyDown(tabs[0], { key: "ArrowRight" }) + expect(tabs[1].getAttribute("aria-selected")).toBe("true") + expect(document.activeElement).toBe(tabs[1]) + + // Wraps around both ends so the group is a single loop + await fireEvent.keyDown(tabs[1], { key: "ArrowLeft" }) + await fireEvent.keyDown(tabs[0], { key: "ArrowLeft" }) + expect(tabs[2].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[2], { key: "ArrowRight" }) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[0], { key: "End" }) + expect(tabs[2].getAttribute("aria-selected")).toBe("true") + + await fireEvent.keyDown(tabs[2], { key: "Home" }) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + }) + + it("leaves keys it does not handle to the browser", async () => { + const { tabs } = render_example_tabs() + + // fireEvent returns false when the default was prevented + expect(await fireEvent.keyDown(tabs[0], { key: "ArrowDown" })).toBe(true) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + + // Alt+Left is browser Back, so the tab group must not swallow it + expect( + await fireEvent.keyDown(tabs[0], { key: "ArrowLeft", altKey: true }), + ).toBe(true) + expect(tabs[0].getAttribute("aria-selected")).toBe("true") + }) + + it("keeps only the active tab in the tab sequence and the panel reachable", async () => { + const { container, tabs } = render_example_tabs() + const panel = container.querySelector('[role="tabpanel"]') + + expect(tabs[0].getAttribute("tabindex")).toBe("0") + expect(tabs[1].getAttribute("tabindex")).toBe("-1") + // The panel scrolls horizontally, so it needs to be keyboard reachable + expect(panel?.getAttribute("tabindex")).toBe("0") + expect(panel?.getAttribute("aria-labelledby")).toBe(tabs[0].id) + expect(tabs[0].getAttribute("aria-controls")).toBe(panel?.id) + + await fireEvent.click(tabs[1]) + + expect(tabs[0].getAttribute("tabindex")).toBe("-1") + expect(tabs[1].getAttribute("tabindex")).toBe("0") + expect(panel?.getAttribute("aria-labelledby")).toBe(tabs[1].id) + }) + + it("shows the selected example's code in the panel", async () => { + const { container, tabs } = render_example_tabs() + const panel = container.querySelector('[role="tabpanel"]') + const first_example_code = panel?.textContent + expect(first_example_code).toContain("def run(") + + await fireEvent.click(tabs[1]) + + expect(panel?.textContent).toContain("def run(") + expect(panel?.textContent).not.toBe(first_example_code) + }) +})