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
65 changes: 62 additions & 3 deletions app/web_ui/src/lib/components/eval_types/code_eval_form.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -260,20 +297,42 @@
]}
>
<div class="flex flex-col gap-4">
<div class="tabs tabs-bordered flex-nowrap overflow-x-auto">
<!-- DaisyUI v4 sets .tabs to display:grid, which keeps every tab on one
row. flex + flex-wrap lets long labels wrap instead of being clipped. -->
<!-- tabs-md is the explicit default size: tabs-sm shrinks the pill to 24px,
where DaisyUI's outline-offset:-5px focus ring cuts through the label. -->
<div
role="tablist"
aria-label="Examples"
class="tabs tabs-boxed tabs-md flex flex-wrap gap-1 w-fit max-w-full"
>
{#each examples as example, i}
<button
bind:this={example_tab_buttons[i]}
type="button"
class="tab shrink-0 whitespace-nowrap {active_example_tab === i
role="tab"
id="{example_tab_id_prefix}_tab_{i}"
aria-selected={active_example_tab === i}
aria-controls="{example_tab_id_prefix}_panel"
tabindex={active_example_tab === i ? 0 : -1}
class="tab min-w-0 justify-start {active_example_tab === i
? 'tab-active'
: ''}"
on:click={() => (active_example_tab = i)}
on:keydown={(event) => on_example_tab_keydown(event, i)}
>
{example.label}
<!-- Truncation needs a block container: text-overflow does nothing on
the flex container .tab itself. -->
<span class="truncate">{example.label}</span>
</button>
{/each}
</div>
<!-- tabindex so keyboard users can scroll the code block (WCAG 2.1.1). -->
<div
role="tabpanel"
id="{example_tab_id_prefix}_panel"
aria-labelledby="{example_tab_id_prefix}_tab_{active_example_tab}"
tabindex="0"
class="bg-base-200 rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre"
>
{examples[active_example_tab].code}
Expand Down
100 changes: 100 additions & 0 deletions app/web_ui/src/lib/components/eval_types/code_eval_form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -522,18 +553,42 @@
]}
>
<div class="flex flex-col gap-4">
<div class="tabs tabs-bordered">
<!-- DaisyUI v4 sets .tabs to display:grid, which keeps every tab on one
row. flex + flex-wrap lets long labels wrap instead of being clipped. -->
<!-- tabs-md is the explicit default size: tabs-sm shrinks the pill to
24px, where DaisyUI's outline-offset:-5px focus ring cuts the label. -->
<div
role="tablist"
aria-label="Examples"
class="tabs tabs-boxed tabs-md flex flex-wrap gap-1 w-fit max-w-full"
>
{#each examples as example, i}
<button
bind:this={example_tab_buttons[i]}
type="button"
class="tab {active_example_tab === i ? 'tab-active' : ''}"
role="tab"
id="code_tool_example_tab_{i}"
aria-selected={active_example_tab === i}
aria-controls="code_tool_example_panel"
tabindex={active_example_tab === i ? 0 : -1}
class="tab min-w-0 justify-start {active_example_tab === i
? 'tab-active'
: ''}"
on:click={() => (active_example_tab = i)}
on:keydown={(event) => on_example_tab_keydown(event, i)}
>
{example.label}
<!-- Truncation needs a block container: text-overflow does nothing
on the flex container .tab itself. -->
<span class="truncate">{example.label}</span>
</button>
{/each}
</div>
<!-- tabindex so keyboard users can scroll the code block (WCAG 2.1.1). -->
<div
role="tabpanel"
id="code_tool_example_panel"
aria-labelledby="code_tool_example_tab_{active_example_tab}"
tabindex="0"
class="bg-base-200 rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre"
>
{examples[active_example_tab].code}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
export let title: string = ""
export let subtitle: string = ""
export let breadcrumbs: Array<Record<string, unknown>> = []
</script>

<div
data-testid="app-page-stub"
data-title={title}
data-subtitle={subtitle}
data-breadcrumbs={JSON.stringify(breadcrumbs)}
>
<slot />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
export let title: string = ""
export let width: string = ""
export let action_buttons: Array<Record<string, unknown>> = []
$: void action_buttons

export function show() {}
export function close() {}
</script>

<div data-testid="dialog-stub" data-title={title} data-width={width}>
<slot />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script lang="ts">
// Stands in for the heavy editor, tools selector and test panel the code step
// renders. Props are declared only to keep Svelte from warning about them.
export let value: string = ""
export let min_height: string = ""
export let project_id: string = ""
export let label: string = ""
export let settings: Record<string, unknown> | undefined = undefined
export let tools: string[] = []
export let code: string = ""
export let tool_function_name: string = ""
export let tool_description: string = ""
export let parameters_schema: Record<string, unknown> | undefined = undefined
export let timeout_seconds: number = 0
export let tool_allowlist: string[] = []
export let has_tested: boolean = false
$: void [
value,
min_height,
project_id,
label,
settings,
tools,
code,
tool_function_name,
tool_description,
parameters_schema,
timeout_seconds,
tool_allowlist,
has_tested,
]

export function setValue(_: string) {}
</script>

<div data-testid="passthrough-stub"><slot /></div>
Loading
Loading