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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>`. `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.

Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<pre>`.

These attributes are stable across patch releases. Treat them like a CSS API.

Expand Down Expand Up @@ -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";

<FormatTabs
aria-label="Catalog formats"
tabs={[
{ id: "preview", label: "Preview", preview: <ControlCatalog data={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 `<pre><code>` 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={<Spinner />}` 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

Expand Down
194 changes: 194 additions & 0 deletions src/interactive/FormatTabs.tsx
Original file line number Diff line number Diff line change
@@ -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. `<ControlCatalog />`),
* or for a loading/empty placeholder (e.g. `<Spinner />`) while a conversion
* is still in flight.
*/
preview?: ReactNode;
/** Raw source text. Rendered in a `<pre><code>` block when no `preview`. */
content?: string;
/**
* Language hint, surfaced as `data-gemara-language` on the code `<pre>`.
* 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<HTMLDivElement>) {
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 (
<div data-gemara-part="format-tabs">
<div
role="tablist"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-gemara-part="format-tablist"
onKeyDown={onKeyDown}
>
{tabs.map((tab, index) => {
const selected = index === current;
return (
<button
key={tab.id}
type="button"
role="tab"
id={tabDomId(index)}
aria-selected={selected}
aria-controls={panelDomId(index)}
tabIndex={selected ? 0 : -1}
data-gemara-part="format-tab"
data-gemara-tab-id={tab.id}
data-gemara-selected={selected ? "" : undefined}
onClick={() => setActiveIndex(index)}
>
{tab.label}
</button>
);
})}
</div>
{tabs.map((tab, index) => {
const selected = index === current;
return (
<div
key={tab.id}
role="tabpanel"
id={panelDomId(index)}
aria-labelledby={tabDomId(index)}
hidden={!selected}
data-gemara-part="format-panel"
data-gemara-tab-id={tab.id}
>
{tab.preview !== undefined ? (
tab.preview
) : (
<pre
data-gemara-part="format-code"
data-gemara-language={tab.language}
>
<code>{tab.content ?? ""}</code>
</pre>
)}
</div>
);
})}
</div>
);
}
5 changes: 5 additions & 0 deletions src/interactive/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading