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 ( + + ); + })} +
+ ); +} 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(); + }); +});