diff --git a/CLAUDE.md b/CLAUDE.md index 374ba06..4d2b945 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ Renderers emit semantic HTML with `data-gemara-*` attributes and no styling. The - `data-gemara-mappings-label="guidelines" | "threats"` to distinguish mapping sections. - `data-gemara-ref="artifact" | "entry" | "mapping-reference"` + `data-gemara-ref-id` on resolver output. - `data-gemara-prose=""` on the wrapper element emitted by the `Prose` primitive (text fields rendered as plain text with `white-space: pre-wrap`). +- `data-gemara-part="format-tabs" | "format-tablist" | "format-tab" | "format-panel" | "format-code"` on the `FormatTabs` interactive viewer, with `data-gemara-tab-id` on tabs/panels, `data-gemara-selected=""` on the active tab, and `data-gemara-language` on the code `
`. `FormatTabs` does no conversion — the consumer supplies the Preview node and pre-converted (gemaraconv, server-side) format strings as props.
Tests assert against these attributes; treat them as a stable contract and update tests in lockstep when changing them.
diff --git a/README.md b/README.md
index 4e1a961..8b305db 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ Renderers are deliberately not in the root barrel — each lives behind its own
| `@gemara/react/vector-catalog` | `VectorCatalog` renderer + compound parts (Layer 1) |
| `@gemara/react/primitives` | `ArtifactRef`, `EntityRef`, `DateTime`, `Prose`, `Heading`, `HeadingScope` |
| `@gemara/react/provider` | `GemaraProvider`, `useLinkResolver`, `ArtifactReference`, `LinkResolver` |
-| `@gemara/react/interactive` | `CollapsibleGroup` (carries `"use client"`) |
+| `@gemara/react/interactive` | `CollapsibleGroup`, `FormatTabs` (carry `"use client"`) |
| `@gemara/react/types` | Raw `Schemas` and discriminated artifact types |
## Styling: the `data-gemara-*` taxonomy
@@ -70,6 +70,8 @@ The library ships no CSS. The public styling contract is the set of `data-gemara
- `data-gemara-mappings-label="guidelines" | "threats" | "principles" | "capabilities" | "vectors"` on mapping sections.
- `data-gemara-ref="artifact" | "entry" | "mapping-reference"` + `data-gemara-ref-id` on resolver output.
- `data-gemara-prose=""` on the `Prose` wrapper element (plain-text fields).
+- `CollapsibleGroup` (interactive) emits `data-gemara-part="collapsible" | "collapsible-trigger" | "collapsible-content"`, plus `data-gemara-open=""` on the wrapper when expanded.
+- `FormatTabs` (interactive) emits `data-gemara-part="format-tabs" | "format-tablist" | "format-tab" | "format-panel" | "format-code"`, plus `data-gemara-tab-id` on tabs/panels, `data-gemara-selected=""` on the active tab, and `data-gemara-language` on each code ``.
These attributes are stable across patch releases. Treat them like a CSS API.
@@ -105,9 +107,33 @@ The resolver receives an `ArtifactReference` (`kind: "artifact" | "entry" | "map
The catalog text fields (`objective`, `description`, `front-matter`, `recommendations.text`, etc.) are rendered as plain text with `white-space: pre-wrap` so authored line breaks survive. The Gemara CUE schema does not type any of these as Markdown — that is a convention, not a contract — so the library makes no parsing promise. If a consumer needs rich formatting, swap the `Prose` primitive locally or pre-render upstream (e.g. in go-gemara) and feed pre-rendered output through your own wrapper.
+## Multiple formats: `FormatTabs`
+
+Gemara artifacts can be projected into other formats — `go-gemara`'s `gemaraconv` turns a Control Catalog into OSCAL and Markdown, a Guidance Catalog into an OSCAL Catalog + Profile, an Evaluation Log into SARIF. `FormatTabs` puts a styled **Preview** alongside tabs that show those raw projections as code blocks.
+
+`gemaraconv` is Go, so it can't run in the browser or an RSC render. The component does **no conversion or fetching** — your server produces the format strings (call the hub API, or run go-gemara) and passes them in, and you supply the Preview as a rendered node. That keeps the library decoupled and headless; `FormatTabs` just displays and manages tab state.
+
+```tsx
+import { FormatTabs } from "@gemara/react/interactive";
+import { ControlCatalog } from "@gemara/react/control-catalog";
+
+ },
+ { id: "markdown", label: "Markdown", language: "markdown", content: markdown },
+ { id: "oscal", label: "OSCAL", language: "json", content: oscalJson },
+ ]}
+/>
+```
+
+Each tab is either a `preview` node (rendered as-is) or `content` text (rendered in a `` with `data-gemara-language` for your highlighter — the library ships no highlighting). A tab with neither is the seam for loading/empty states: pass `preview={ }` while a conversion is still in flight.
+
+It implements the ARIA tabs pattern with **automatic activation** — Arrow keys move focus and switch the panel in one step (Left/Right wrap; Home/End jump to first/last). Provide `aria-label` (or `aria-labelledby`) so the tablist has an accessible name — important when several viewers share a page. Because it holds tab state, it lives in `@gemara/react/interactive` and carries `"use client"`.
+
## React Server Components
-The default catalog renderers and all primitives are server-component-safe. The only interactive component (`CollapsibleGroup`) lives in `@gemara/react/interactive` and carries a `"use client"` directive in its built output — RSC bundlers route it into the client graph automatically.
+The default catalog renderers and all primitives are server-component-safe. The interactive components (`CollapsibleGroup`, `FormatTabs`) live in `@gemara/react/interactive` and carry a `"use client"` directive in their built output — RSC bundlers route them into the client graph automatically.
## Spec version
diff --git a/src/interactive/FormatTabs.tsx b/src/interactive/FormatTabs.tsx
new file mode 100644
index 0000000..4b02ddb
--- /dev/null
+++ b/src/interactive/FormatTabs.tsx
@@ -0,0 +1,194 @@
+// SPDX-License-Identifier: Apache-2.0
+"use client";
+
+import {
+ useEffect,
+ useId,
+ useState,
+ type KeyboardEvent,
+ type ReactNode,
+} from "react";
+
+export interface FormatTab {
+ /**
+ * Stable identity for the tab. Emitted as `data-gemara-tab-id` (a styling /
+ * selector hook) and used as the React key. Should be unique within `tabs`.
+ */
+ id: string;
+ /** Visible label for the tab trigger. */
+ label: ReactNode;
+ /**
+ * Rendered preview node. When present, the panel renders this instead of a
+ * code block — use it for the styled "Preview" tab (e.g. ` `),
+ * or for a loading/empty placeholder (e.g. ` `) while a conversion
+ * is still in flight.
+ */
+ preview?: ReactNode;
+ /** Raw source text. Rendered in a `` block when no `preview`. */
+ content?: string;
+ /**
+ * Language hint, surfaced as `data-gemara-language` on the code ``.
+ * The library ships no highlighting; consumers style/highlight off this.
+ */
+ language?: string;
+}
+
+export interface FormatTabsProps {
+ /** Ordered tabs. The first is active unless `defaultTabId` overrides it. */
+ tabs: FormatTab[];
+ /** Id of the tab to activate initially. Defaults to the first tab. */
+ defaultTabId?: string;
+ /** Accessible label for the tablist. Provide this or `aria-labelledby`. */
+ "aria-label"?: string;
+ /** Id of an external element labelling the tablist. */
+ "aria-labelledby"?: string;
+}
+
+/**
+ * Tabbed viewer for an artifact's available representations.
+ *
+ * The conversions themselves (OSCAL, Markdown, SARIF, …) are produced by
+ * go-gemara server-side and passed in as strings — this component does no
+ * fetching or conversion, keeping the library decoupled and headless. The
+ * "Preview" tab is supplied by the consumer as a rendered node, so the viewer
+ * imports no renderers and stays artifact-agnostic + tree-shakeable.
+ *
+ * Lives in the `interactive` subpath export: the `useState` for the active tab
+ * forces a client island, so RSC consumers who never reach for it pay no
+ * client-bundle cost. Implements the ARIA tabs pattern with **automatic
+ * activation** — arrow keys move focus and switch the panel in one step
+ * (Left/Right wrap; Home/End jump to the first/last tab).
+ *
+ * DOM ids and selection are keyed by tab *position*, not by `tab.id`, so the
+ * ARIA wiring (`aria-controls` / `aria-labelledby`) stays valid even if a
+ * consumer accidentally supplies duplicate ids.
+ */
+export function FormatTabs({
+ tabs,
+ defaultTabId,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledBy,
+}: FormatTabsProps) {
+ const baseId = useId();
+ const [activeIndex, setActiveIndex] = useState(() => {
+ if (defaultTabId === undefined) return 0;
+ const i = tabs.findIndex((t) => t.id === defaultTabId);
+ return i >= 0 ? i : 0;
+ });
+
+ const idSignature = tabs.map((t) => t.id).join(",");
+ useEffect(() => {
+ if (process.env.NODE_ENV === "production") return;
+ if (tabs.length === 0) return;
+ const ids = tabs.map((t) => t.id);
+ if (new Set(ids).size !== ids.length) {
+ console.warn(
+ "[FormatTabs] duplicate tab ids — `data-gemara-tab-id` selectors and React keys require unique ids:",
+ ids,
+ );
+ }
+ if (defaultTabId !== undefined && !ids.includes(defaultTabId)) {
+ console.warn(
+ `[FormatTabs] defaultTabId "${defaultTabId}" matches no tab; falling back to the first tab.`,
+ );
+ }
+ if (ariaLabel === undefined && ariaLabelledBy === undefined) {
+ console.warn(
+ "[FormatTabs] tablist has no accessible name — pass `aria-label` or `aria-labelledby`.",
+ );
+ }
+ // idSignature stands in for the tab ids so the check re-runs when they change.
+ }, [idSignature, defaultTabId, ariaLabel, ariaLabelledBy, tabs]);
+
+ if (tabs.length === 0) return null;
+
+ // Clamp in case the tab list shrank below the remembered index.
+ const current = activeIndex < tabs.length ? activeIndex : 0;
+ const tabDomId = (index: number) => `${baseId}-tab-${index}`;
+ const panelDomId = (index: number) => `${baseId}-panel-${index}`;
+
+ function activate(index: number) {
+ setActiveIndex(index);
+ document.getElementById(tabDomId(index))?.focus();
+ }
+
+ function onKeyDown(e: KeyboardEvent) {
+ let next = current;
+ switch (e.key) {
+ case "ArrowRight":
+ next = (current + 1) % tabs.length;
+ break;
+ case "ArrowLeft":
+ next = (current - 1 + tabs.length) % tabs.length;
+ break;
+ case "Home":
+ next = 0;
+ break;
+ case "End":
+ next = tabs.length - 1;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ activate(next);
+ }
+
+ return (
+
+
+ {tabs.map((tab, index) => {
+ const selected = index === current;
+ return (
+
+ );
+ })}
+
+ {tabs.map((tab, index) => {
+ const selected = index === current;
+ return (
+
+ {tab.preview !== undefined ? (
+ tab.preview
+ ) : (
+
+ {tab.content ?? ""}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/interactive/index.ts b/src/interactive/index.ts
index 389ac9c..8fb1c01 100644
--- a/src/interactive/index.ts
+++ b/src/interactive/index.ts
@@ -1,2 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
export { CollapsibleGroup, type CollapsibleGroupProps } from "./CollapsibleGroup.js";
+export {
+ FormatTabs,
+ type FormatTab,
+ type FormatTabsProps,
+} from "./FormatTabs.js";
diff --git a/tests/FormatTabs.test.tsx b/tests/FormatTabs.test.tsx
new file mode 100644
index 0000000..0993ed0
--- /dev/null
+++ b/tests/FormatTabs.test.tsx
@@ -0,0 +1,200 @@
+// SPDX-License-Identifier: Apache-2.0
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { parse as parseYaml } from "yaml";
+import { render, fireEvent } from "@testing-library/react";
+import { FormatTabs } from "../src/interactive/index.js";
+import { ControlCatalog } from "../src/control-catalog/index.js";
+import { isControlCatalog } from "../src/generated/types.js";
+import type { ControlCatalog as ControlCatalogData } from "../src/generated/types.js";
+
+const OSCAL = '{"catalog":{"uuid":"abc"}}';
+const MARKDOWN = "# Catalog\n\nline one\nline two";
+
+function renderTabs(defaultTabId?: string) {
+ return render(
+ rendered preview },
+ { id: "markdown", label: "Markdown", language: "markdown", content: MARKDOWN },
+ { id: "oscal", label: "OSCAL", language: "json", content: OSCAL },
+ ]}
+ />,
+ );
+}
+
+function selected(getByRole: ReturnType["getByRole"], name: string) {
+ return getByRole("tab", { name }).getAttribute("aria-selected");
+}
+
+describe("FormatTabs", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("activates the first tab by default", () => {
+ const { getByRole } = renderTabs();
+ expect(selected(getByRole, "Preview")).toBe("true");
+ expect(selected(getByRole, "OSCAL")).toBe("false");
+ });
+
+ it("honors defaultTabId", () => {
+ const { getByRole } = renderTabs("oscal");
+ expect(selected(getByRole, "OSCAL")).toBe("true");
+ });
+
+ it("renders a code block with exact content + language hint", () => {
+ const { getByRole } = renderTabs("oscal");
+ const pre = getByRole("tabpanel").querySelector(
+ '[data-gemara-part="format-code"]',
+ );
+ expect(pre).not.toBeNull();
+ expect(pre?.getAttribute("data-gemara-language")).toBe("json");
+ expect(pre?.textContent).toBe(OSCAL);
+ });
+
+ it("renders the supplied preview node verbatim on the preview tab", () => {
+ const { getByText, getByRole } = renderTabs("preview");
+ expect(getByText("rendered preview")).not.toBeNull();
+ // getByRole excludes hidden panels — only the active one is exposed.
+ expect(getByRole("tabpanel").getAttribute("data-gemara-tab-id")).toBe(
+ "preview",
+ );
+ });
+
+ it("switches panels on click and toggles hidden + aria-selected", () => {
+ const { getByRole, container } = renderTabs();
+ fireEvent.click(getByRole("tab", { name: "Markdown" }));
+ expect(selected(getByRole, "Markdown")).toBe("true");
+ const panels = container.querySelectorAll('[data-gemara-part="format-panel"]');
+ const md = container.querySelector(
+ '[data-gemara-part="format-panel"][data-gemara-tab-id="markdown"]',
+ );
+ const preview = container.querySelector(
+ '[data-gemara-part="format-panel"][data-gemara-tab-id="preview"]',
+ );
+ expect(panels.length).toBe(3);
+ expect(md?.hasAttribute("hidden")).toBe(false);
+ expect(preview?.hasAttribute("hidden")).toBe(true);
+ });
+
+ it("moves selection with ArrowRight, ArrowLeft, Home, and End", () => {
+ const { getByRole } = renderTabs();
+ const tablist = getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowRight" });
+ expect(selected(getByRole, "Markdown")).toBe("true");
+ fireEvent.keyDown(tablist, { key: "End" });
+ expect(selected(getByRole, "OSCAL")).toBe("true");
+ fireEvent.keyDown(tablist, { key: "Home" });
+ expect(selected(getByRole, "Preview")).toBe("true");
+ // ArrowLeft wraps from the first tab to the last.
+ fireEvent.keyDown(tablist, { key: "ArrowLeft" });
+ expect(selected(getByRole, "OSCAL")).toBe("true");
+ });
+
+ it("moves focus to the newly activated tab (automatic activation)", () => {
+ const { getByRole } = renderTabs();
+ const tablist = getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowRight" });
+ expect(document.activeElement).toBe(getByRole("tab", { name: "Markdown" }));
+ fireEvent.keyDown(tablist, { key: "End" });
+ expect(document.activeElement).toBe(getByRole("tab", { name: "OSCAL" }));
+ });
+
+ it("applies a roving tabindex (only the active tab is in the tab order)", () => {
+ const { getByRole } = renderTabs("markdown");
+ expect(getByRole("tab", { name: "Preview" }).getAttribute("tabindex")).toBe("-1");
+ expect(getByRole("tab", { name: "Markdown" }).getAttribute("tabindex")).toBe("0");
+ });
+
+ it("keeps DOM ids unique even when tab ids collide", () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { container } = render(
+ ,
+ );
+ const tabs = [...container.querySelectorAll('[role="tab"]')];
+ const panels = [...container.querySelectorAll('[role="tabpanel"]')];
+ const tabIds = tabs.map((t) => t.id);
+ const panelIds = panels.map((p) => p.id);
+ expect(new Set(tabIds).size).toBe(2); // distinct DOM ids despite duplicate tab.id
+ expect(new Set(panelIds).size).toBe(2);
+ // Each tab points at a distinct, existing panel.
+ tabs.forEach((t) => {
+ const target = t.getAttribute("aria-controls");
+ expect(panelIds).toContain(target);
+ });
+ });
+
+ it("falls back to the first tab and warns when defaultTabId matches nothing", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { getByRole } = renderTabs("does-not-exist");
+ expect(selected(getByRole, "Preview")).toBe("true");
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("does-not-exist"),
+ );
+ });
+
+ it("warns in dev when the tablist has no accessible name", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ render( );
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("accessible name"),
+ );
+ });
+
+ it("accepts aria-labelledby as the tablist name", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { getByRole } = render(
+ ,
+ );
+ expect(getByRole("tablist").getAttribute("aria-labelledby")).toBe("ext-heading");
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("wires aria-controls / aria-labelledby between tab and panel", () => {
+ const { getByRole } = renderTabs("markdown");
+ const tab = getByRole("tab", { name: "Markdown" });
+ const panel = getByRole("tabpanel");
+ expect(tab.getAttribute("aria-controls")).toBe(panel.id);
+ expect(panel.getAttribute("aria-labelledby")).toBe(tab.id);
+ });
+
+ it("returns null for an empty tab set", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("uses a real renderer as the preview tab", () => {
+ const raw = readFileSync(
+ resolve(__dirname, "..", "..", "gemara", "test", "test-data", "good-ccc.yaml"),
+ "utf8",
+ );
+ const parsed = parseYaml(raw);
+ if (!isControlCatalog(parsed)) throw new Error("fixture is not a ControlCatalog");
+ const data = parsed as ControlCatalogData;
+ const { container } = render(
+ },
+ { id: "oscal", label: "OSCAL", language: "json", content: OSCAL },
+ ]}
+ />,
+ );
+ expect(
+ container.querySelector('[data-gemara-artifact="ControlCatalog"]'),
+ ).not.toBeNull();
+ });
+});