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) => (
@@ -530,6 +544,52 @@ describe("BlockRenderer", () => { expect(screen.getByText("Right")).toBeTruthy(); }); + it("tab block renders panel labels and shows first panel by default", () => { + renderBlocks([ + { + type: "tab", + panels: [ + { label: "General", blocks: [{ type: "header", text: "General Settings" }] }, + { label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] }, + ], + }, + ]); + expect(screen.getByText("General")).toBeTruthy(); + expect(screen.getByText("Advanced")).toBeTruthy(); + expect(screen.getByText("General Settings")).toBeTruthy(); + expect(screen.queryByText("Advanced Settings")).toBeNull(); + }); + + it("tab block switches panel on tab click", () => { + renderBlocks([ + { + type: "tab", + panels: [ + { label: "General", blocks: [{ type: "header", text: "General Settings" }] }, + { label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] }, + ], + }, + ]); + fireEvent.click(screen.getByText("Advanced")); + expect(screen.queryByText("General Settings")).toBeNull(); + expect(screen.getByText("Advanced Settings")).toBeTruthy(); + }); + + it("tab block respects default_tab", () => { + renderBlocks([ + { + type: "tab", + default_tab: 1, + panels: [ + { label: "General", blocks: [{ type: "header", text: "General Settings" }] }, + { label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] }, + ], + }, + ]); + expect(screen.queryByText("General Settings")).toBeNull(); + expect(screen.getByText("Advanced Settings")).toBeTruthy(); + }); + it("button click fires onAction with block_action", () => { const onAction = vi.fn(); renderBlocks(