diff --git a/.changeset/lazy-shrimps-stand.md b/.changeset/lazy-shrimps-stand.md
new file mode 100644
index 000000000..e5715a16a
--- /dev/null
+++ b/.changeset/lazy-shrimps-stand.md
@@ -0,0 +1,5 @@
+---
+"@emdash-cms/blocks": minor
+---
+
+Add Tab block to Block Kit for tabbed panel layouts
diff --git a/packages/blocks/src/blocks/tab.tsx b/packages/blocks/src/blocks/tab.tsx
new file mode 100644
index 000000000..305c1e6ee
--- /dev/null
+++ b/packages/blocks/src/blocks/tab.tsx
@@ -0,0 +1,30 @@
+import { Tabs } from "@cloudflare/kumo";
+import { useState } from "react";
+
+import { BlockRenderer } from "../renderer.js";
+import type { BlockInteraction, TabBlock } from "../types.js";
+
+export function TabBlockComponent({
+ block,
+ onAction,
+}: {
+ block: TabBlock;
+ onAction: (interaction: BlockInteraction) => void;
+}) {
+ const [activeTab, setActiveTab] = useState(block.default_tab ?? 0);
+ const tabs = block.panels.map((panel, i) => ({ value: String(i), label: panel.label }));
+
+ return (
+
+
setActiveTab(Number(value))}
+ tabs={tabs}
+ />
+
+
+
+
+ );
+}
diff --git a/packages/blocks/src/builders.ts b/packages/blocks/src/builders.ts
index 338b72285..ce71b2d02 100644
--- a/packages/blocks/src/builders.ts
+++ b/packages/blocks/src/builders.ts
@@ -36,6 +36,8 @@ import type {
TableColumn,
TextInputElement,
ToggleElement,
+ TabBlock,
+ TabPanel,
} from "./types.js";
// ── Block Builders ───────────────────────────────────────────────────────────
@@ -444,6 +446,21 @@ function codeBlock(opts: {
};
}
+function tabBlock(
+ panels: TabPanel[],
+ opts?: {
+ defaultTab?: number;
+ blockId?: string;
+ },
+): TabBlock {
+ return {
+ type: "tab",
+ panels,
+ ...(opts?.defaultTab !== undefined && { default_tab: opts.defaultTab }),
+ ...(opts?.blockId !== undefined && { block_id: opts.blockId }),
+ };
+}
+
function empty(opts: {
blockId?: string;
title: string;
@@ -497,6 +514,7 @@ export const blocks = {
banner: bannerBlock,
meter,
code: codeBlock,
+ tab: tabBlock,
empty,
accordion,
};
diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts
index ecedada3a..dfd4910ab 100644
--- a/packages/blocks/src/index.ts
+++ b/packages/blocks/src/index.ts
@@ -36,6 +36,7 @@ export type {
ChartConfig,
TimeseriesChartConfig,
CustomChartConfig,
+ TabPanel,
// Blocks
HeaderBlock,
SectionBlock,
@@ -50,6 +51,7 @@ export type {
ColumnsBlock,
ChartBlock,
CodeBlock,
+ TabBlock,
BannerBlock,
MeterBlock,
EmptyBlock,
diff --git a/packages/blocks/src/renderer.tsx b/packages/blocks/src/renderer.tsx
index 672a1931c..2473c3794 100644
--- a/packages/blocks/src/renderer.tsx
+++ b/packages/blocks/src/renderer.tsx
@@ -14,6 +14,7 @@ import { ImageBlockComponent } from "./blocks/image.js";
import { MeterBlockComponent } from "./blocks/meter.js";
import { SectionBlockComponent } from "./blocks/section.js";
import { StatsBlockComponent } from "./blocks/stats.js";
+import { TabBlockComponent } from "./blocks/tab.js";
import { TableBlockComponent } from "./blocks/table.js";
import type { Block, BlockInteraction } from "./types.js";
@@ -52,6 +53,8 @@ function renderBlock(
return ;
case "code":
return ;
+ case "tab":
+ return ;
case "empty":
return ;
case "accordion":
diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts
index 070b164ac..5105a9734 100644
--- a/packages/blocks/src/types.ts
+++ b/packages/blocks/src/types.ts
@@ -337,6 +337,17 @@ export interface CodeBlock extends BlockBase {
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
}
+export interface TabPanel {
+ label: string;
+ blocks: Block[];
+}
+
+export interface TabBlock extends BlockBase {
+ type: "tab";
+ panels: TabPanel[];
+ default_tab?: number;
+}
+
export interface EmptyBlock extends BlockBase {
type: "empty";
title: string;
@@ -369,6 +380,7 @@ export type Block =
| BannerBlock
| MeterBlock
| CodeBlock
+ | TabBlock
| EmptyBlock
| AccordionBlock;
diff --git a/packages/blocks/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx
index 2b53e8edc..4c06d65d9 100644
--- a/packages/blocks/tests/renderer.test.tsx
+++ b/packages/blocks/tests/renderer.test.tsx
@@ -117,6 +117,20 @@ vi.mock("@cloudflare/kumo", () => ({
{contents}
),
+ Tabs: ({ tabs, value, onValueChange }: any) => (
+
+ {tabs.map((tab: any) => (
+
+ ))}
+
+ ),
Checkbox: {
Group: ({ children, legend }: any) => (