From f91e314af08e2cb8d02b9c5d4515c9ef8f0c7289 Mon Sep 17 00:00:00 2001
From: Eddie Knight `. `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";
+
+` 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={` 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
+
+ )}
+ {tab.content ?? ""}
+