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
33 changes: 33 additions & 0 deletions playground/src/components/SegmentedTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { SegmentedTabs } from "@/components/SegmentedTabs";

describe("SegmentedTabs", () => {
it("reports the selected Kumo tab", () => {
const onChange = vi.fn();
render(
<SegmentedTabs
id="modes"
label="Modes"
items={[
{ value: "form", label: "Form" },
{ value: "json", label: "JSON" },
]}
value="form"
onChange={onChange}
/>,
);

const form = screen.getByRole("tab", { name: "Form" });
const json = screen.getByRole("tab", { name: "JSON" });
expect(screen.getByRole("tablist", { name: "Modes" })).toBeVisible();
expect(form).toHaveAttribute("aria-selected", "true");
expect(form).toHaveAttribute("id", "modes-tab-form");
expect(form).toHaveAttribute("aria-controls", "modes-panel-form");
expect(json).toHaveAttribute("aria-selected", "false");

fireEvent.click(json);
expect(onChange).toHaveBeenCalledWith("json");
});
});
65 changes: 65 additions & 0 deletions playground/src/components/SegmentedTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Tabs } from "@cloudflare/kumo/components/tabs";
import { useEffect, useRef } from "react";

type TabItem<Value extends string> = {
value: Value;
label: string;
};

type Props<Value extends string> = {
id: string;
label: string;
items: readonly TabItem<Value>[];
value: Value;
onChange: (value: Value) => void;
};

/** Generates the ID referenced by a matching panel's `aria-labelledby`. */
export function tabId(id: string, value: string): string {
return `${id}-tab-${value}`;
}

/** Generates the panel ID referenced by a matching tab's `aria-controls`. */
export function tabPanelId(id: string, value: string): string {
return `${id}-panel-${value}`;
}

/** Adds stable ARIA tab/panel relationships around Kumo's controlled tabs. */
export function SegmentedTabs<Value extends string>({
id,
label,
items,
value,
onChange,
}: Props<Value>) {
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
root.current?.querySelector('[role="tablist"]')?.setAttribute("aria-label", label);
}, [label]);

return (
<div ref={root} className="playground-segmented">
<Tabs
tabs={items.map((item) => ({
...item,
render: (
<button
id={tabId(id, item.value)}
type="button"
aria-label={item.label}
aria-controls={tabPanelId(id, item.value)}
/>
),
}))}
value={value}
activateOnFocus
onValueChange={(next) => {
const item = items.find((candidate) => candidate.value === next);
if (item) onChange(item.value);
}}
variant="segmented"
size="sm"
/>
</div>
);
}
16 changes: 16 additions & 0 deletions playground/src/components/StatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { StatusBadge } from "@/components/StatusBadge";

describe("StatusBadge", () => {
it.each([
["READY", "ready"],
["SETUP_FAILED", "setup_failed"],
["canceled", "canceled"],
[undefined, "unknown"],
])("normalizes %s", (status, label) => {
render(<StatusBadge status={status} />);
expect(screen.getByText(label)).toBeVisible();
});
});
28 changes: 28 additions & 0 deletions playground/src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Badge, type BadgeVariant } from "@cloudflare/kumo/components/badge";

/** Maps known Cog statuses to semantic colors and renders unknown values neutrally. */
export function StatusBadge({ status }: { status?: string }) {
const normalized = status?.toLowerCase() || "unknown";
const variants: Record<string, BadgeVariant> = {
ready: "success",
succeeded: "success",
busy: "warning",
starting: "warning",
processing: "warning",
defunct: "error",
failed: "error",
error: "error",
setup_failed: "error",
unhealthy: "error",
unreachable: "error",
canceled: "neutral",
unknown: "neutral",
};
return (
<output>
<Badge variant={variants[normalized] ?? "neutral"} appearance="dot">
{normalized}
</Badge>
</output>
);
}
157 changes: 157 additions & 0 deletions playground/src/components/editor/JsonEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { JsonEditor } from "@/components/editor/JsonEditor";

describe("JsonEditor", () => {
it("renders highlighted read-only JSON and follows controlled updates", async () => {
const { container, rerender } = render(
<JsonEditor value={'{\n "answer": 42\n}'} label="Response JSON" readOnly />,
);

const editor = screen.getByRole("textbox", { name: "Response JSON" });
expect(editor).toHaveAttribute("aria-readonly", "true");
expect(editor).toHaveAttribute("contenteditable", "false");
expect(container.querySelector(".cm-gutters")).toBeInTheDocument();
expect(container.querySelector(".cm-foldGutter")).toBeInTheDocument();
expect(container.querySelector(".cm-content span")).toBeInTheDocument();

rerender(<JsonEditor value={'{"answer":43}'} label="Response JSON" readOnly />);
await waitFor(() => expect(editor).toHaveTextContent('"answer":43'));
});

it("follows the tail until the user scrolls away", async () => {
const { container, rerender } = render(
<JsonEditor value="first" label="Live response" readOnly followTail />,
);
const scroller = container.querySelector<HTMLElement>(".cm-scroller");
if (!scroller) throw new Error("CodeMirror scroller was not rendered");

let scrollHeight = 1_000;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, get: () => scrollHeight },
scrollTop: { configurable: true, value: 0, writable: true },
});

rerender(<JsonEditor value="first\nsecond" label="Live response" readOnly followTail />);
await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight));
await nextAnimationFrame();

scroller.scrollTop = 300;
fireEvent.scroll(scroller);
scrollHeight = 1_200;
rerender(<JsonEditor value="first\nsecond\nthird" label="Live response" readOnly followTail />);
await nextAnimationFrame();

expect(scroller.scrollTop).toBe(300);

scrollHeight = 1_300;
rerender(
<JsonEditor
value="first\nsecond\nthird\nfourth"
label="Live response"
readOnly
followTail
active={false}
/>,
);
rerender(
<JsonEditor value="first\nsecond\nthird\nfourth" label="Live response" readOnly followTail />,
);
await nextAnimationFrame();

expect(scroller.scrollTop).toBe(300);

rerender(
<JsonEditor
value="first\nsecond\nthird\nfourth"
label="Live response"
readOnly
followTail={false}
/>,
);
scrollHeight = 1_400;
rerender(
<JsonEditor
value="first\nsecond\nthird\nfourth\nfifth"
label="Live response"
readOnly
followTail
/>,
);

await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight));
});

it("does not override a user scroll while a tail measurement is queued", async () => {
const { container, rerender } = render(
<JsonEditor value="first" label="Queued response" readOnly followTail={false} />,
);
const scroller = container.querySelector<HTMLElement>(".cm-scroller");
if (!scroller) throw new Error("CodeMirror scroller was not rendered");
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 200 },
scrollHeight: { configurable: true, value: 1_000 },
scrollTop: { configurable: true, value: 0, writable: true },
});

rerender(<JsonEditor value="first\nsecond" label="Queued response" readOnly followTail />);
fireEvent.wheel(scroller, { deltaY: -100 });
scroller.scrollTop = 300;
fireEvent.scroll(scroller);
await nextAnimationFrame();

expect(scroller.scrollTop).toBe(300);
});

it("copies the complete document", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
render(<JsonEditor value={'{"answer":42}'} label="Copy JSON" />);

fireEvent.click(screen.getByRole("button", { name: "Copy Copy JSON" }));

await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"answer":42}'));
});

it("removes disabled editors and copy controls from keyboard navigation", () => {
render(
<JsonEditor value="{}" label="Disabled JSON" disabled invalid describedBy="disabled-help" />,
);

const editor = screen.getByRole("textbox", { name: "Disabled JSON" });
expect(editor).toHaveAttribute("tabindex", "-1");
expect(editor).toHaveAttribute("aria-disabled", "true");
expect(editor).toHaveAttribute("aria-invalid", "true");
expect(editor).toHaveAttribute("aria-describedby", "disabled-help");
expect(screen.getByRole("button", { name: "Copy Disabled JSON" })).toBeDisabled();
});

it("leaves Tab available for keyboard navigation", () => {
render(<JsonEditor value="{}" label="Input JSON" />);
const editor = screen.getByRole("textbox", { name: "Input JSON" });
editor.focus();

expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab" })).toBe(true);
});

it("does not add controlled updates to undo history", async () => {
const { rerender } = render(<JsonEditor value={'{"answer":42}'} label="Updated JSON" />);
const editor = screen.getByRole("textbox", { name: "Updated JSON" });
Comment thread
anish-sahoo marked this conversation as resolved.

rerender(<JsonEditor value={'{"answer":43}'} label="Updated JSON" />);
await waitFor(() => expect(editor).toHaveTextContent('"answer":43'));
editor.focus();
fireEvent.keyDown(editor, { key: "z", code: "KeyZ", metaKey: true });

expect(editor).toHaveTextContent('"answer":43');
});
});

function nextAnimationFrame(): Promise<void> {
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
Loading
Loading