From 2a05c14dd40a44a0cdcf71a5b503be946f1bc70f Mon Sep 17 00:00:00 2001 From: Mason James Date: Sun, 3 May 2026 18:32:07 -0400 Subject: [PATCH 1/2] Add item list Block Kit block --- .changeset/blockkit-item-list.md | 5 ++ .../plugins/creating-plugins/block-kit.mdx | 1 + packages/blocks/src/blocks/item-list.tsx | 77 +++++++++++++++++++ packages/blocks/src/builders.ts | 18 +++++ packages/blocks/src/index.ts | 2 + packages/blocks/src/renderer.tsx | 3 + packages/blocks/src/types.ts | 20 ++++- packages/blocks/src/validation.ts | 71 +++++++++++++++++ packages/blocks/tests/renderer.test.tsx | 50 ++++++++++++ packages/blocks/tests/validation.test.ts | 51 ++++++++++++ 10 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 .changeset/blockkit-item-list.md create mode 100644 packages/blocks/src/blocks/item-list.tsx diff --git a/.changeset/blockkit-item-list.md b/.changeset/blockkit-item-list.md new file mode 100644 index 0000000000..578b06087d --- /dev/null +++ b/.changeset/blockkit-item-list.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/blocks": patch +--- + +Adds an `item_list` Block Kit block for compact rich entity lists with optional metadata, badges, avatars, icons, and button actions. diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx index dd03f112eb..9426c8128c 100644 --- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx @@ -92,6 +92,7 @@ The standard-format route handler takes two arguments: `routeCtx` (with `input`, | `columns` | 2–3 column layout with nested blocks | | `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons | | `accordion` | Collapsible section wrapping nested blocks | +| `item_list` | Compact rich list for files, tasks, search results, and plugin entities | ## Element types diff --git a/packages/blocks/src/blocks/item-list.tsx b/packages/blocks/src/blocks/item-list.tsx new file mode 100644 index 0000000000..3d9b945bd1 --- /dev/null +++ b/packages/blocks/src/blocks/item-list.tsx @@ -0,0 +1,77 @@ +import { Badge } from "@cloudflare/kumo"; + +import { renderElement } from "../render-element.js"; +import type { BlockInteraction, ItemListBlock } from "../types.js"; +import { cn } from "../utils.js"; + +const HAS_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const HTTP_URL_RE = /^https?:\/\//i; + +function isSafePreviewUrl(url: string): boolean { + if (!url) return false; + if (HAS_SCHEME_RE.test(url)) { + return HTTP_URL_RE.test(url); + } + return url.startsWith("/") && !url.startsWith("//"); +} + +export function ItemListBlockComponent({ + block, + onAction, +}: { + block: ItemListBlock; + onAction: (interaction: BlockInteraction) => void; +}) { + if (block.items.length === 0 && block.empty_text) { + return

{block.empty_text}

; + } + + const compact = block.density === "compact"; + + return ( +
+ {block.items.map((item, i) => { + const canPreviewAvatar = item.avatar_url ? isSafePreviewUrl(item.avatar_url) : false; + return ( +
+ {canPreviewAvatar ? ( + + ) : item.icon ? ( +
+ {item.icon} +
+ ) : null} +
+
+

{item.title}

+ {item.badge && {item.badge}} + {item.meta && {item.meta}} +
+ {item.description && ( +

+ {item.description} +

+ )} + {item.actions && item.actions.length > 0 && ( +
+ {item.actions.map((action, actionIndex) => ( +
{renderElement(action, onAction)}
+ ))} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/packages/blocks/src/builders.ts b/packages/blocks/src/builders.ts index ce71b2d027..f8b1419f64 100644 --- a/packages/blocks/src/builders.ts +++ b/packages/blocks/src/builders.ts @@ -24,6 +24,8 @@ import type { FormField, HeaderBlock, ImageBlock, + ItemListBlock, + ListItem, MediaPickerElement, MeterBlock, NumberInputElement, @@ -495,6 +497,21 @@ function accordion(opts: { }; } +function itemList(opts: { + blockId?: string; + items: ListItem[]; + emptyText?: string; + density?: "default" | "compact"; +}): ItemListBlock { + return { + type: "item_list", + items: opts.items, + ...(opts.emptyText !== undefined && { empty_text: opts.emptyText }), + ...(opts.density !== undefined && { density: opts.density }), + ...(opts.blockId !== undefined && { block_id: opts.blockId }), + }; +} + // ── Exports ────────────────────────────────────────────────────────────────── export const blocks = { @@ -517,6 +534,7 @@ export const blocks = { tab: tabBlock, empty, accordion, + itemList, }; export const elements = { diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index dfd4910ab7..46df17b5a1 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -32,6 +32,7 @@ export type { // Block sub-types TableColumn, StatItem, + ListItem, ChartSeries, ChartConfig, TimeseriesChartConfig, @@ -56,6 +57,7 @@ export type { MeterBlock, EmptyBlock, AccordionBlock, + ItemListBlock, Block, // Interactions BlockAction, diff --git a/packages/blocks/src/renderer.tsx b/packages/blocks/src/renderer.tsx index 2473c37942..0f03de65b8 100644 --- a/packages/blocks/src/renderer.tsx +++ b/packages/blocks/src/renderer.tsx @@ -11,6 +11,7 @@ import { FieldsBlockComponent } from "./blocks/fields.js"; import { FormBlockComponent } from "./blocks/form.js"; import { HeaderBlockComponent } from "./blocks/header.js"; import { ImageBlockComponent } from "./blocks/image.js"; +import { ItemListBlockComponent } from "./blocks/item-list.js"; import { MeterBlockComponent } from "./blocks/meter.js"; import { SectionBlockComponent } from "./blocks/section.js"; import { StatsBlockComponent } from "./blocks/stats.js"; @@ -59,6 +60,8 @@ function renderBlock( return ; case "accordion": return ; + case "item_list": + return ; default: { const _exhaustive: never = block; return null; diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 5105a9734b..7449b0f381 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -201,6 +201,16 @@ export interface StatItem { trend?: "up" | "down" | "neutral"; } +export interface ListItem { + title: string; + description?: string; + meta?: string; + badge?: string; + icon?: string; + avatar_url?: string; + actions?: ButtonElement[]; +} + /** A single data series for a timeseries chart. */ export interface ChartSeries { /** Display name shown in tooltips and legends */ @@ -364,6 +374,13 @@ export interface AccordionBlock extends BlockBase { default_open?: boolean; } +export interface ItemListBlock extends BlockBase { + type: "item_list"; + items: ListItem[]; + empty_text?: string; + density?: "default" | "compact"; +} + export type Block = | HeaderBlock | SectionBlock @@ -382,7 +399,8 @@ export type Block = | CodeBlock | TabBlock | EmptyBlock - | AccordionBlock; + | AccordionBlock + | ItemListBlock; // ── Interactions ───────────────────────────────────────────────────────────── diff --git a/packages/blocks/src/validation.ts b/packages/blocks/src/validation.ts index 9f39163b74..c56fc01dec 100644 --- a/packages/blocks/src/validation.ts +++ b/packages/blocks/src/validation.ts @@ -16,6 +16,7 @@ const BLOCK_TYPES = new Set([ "code", "empty", "accordion", + "item_list", ]); const EMPTY_SIZES = new Set(["sm", "base", "lg"]); @@ -44,6 +45,7 @@ const CODE_LANGUAGES = new Set(["ts", "tsx", "jsonc", "bash", "css"]); const BUTTON_STYLES = new Set(["primary", "danger", "secondary"]); const TREND_VALUES = new Set(["up", "down", "neutral"]); const BANNER_VARIANTS = new Set(["default", "alert", "error"]); +const ITEM_LIST_DENSITIES = new Set(["default", "compact"]); /** * RFC 6838-style image MIME type or image-prefix. @@ -612,6 +614,17 @@ function validateFormField(value: unknown, path: string, errors: ValidationError } } +function validateButtonAction(value: unknown, path: string, errors: ValidationError[]): void { + if (isRecord(value) && value.type !== "button") { + errors.push({ + path: `${path}.type`, + message: "Action must be a button element", + }); + return; + } + validateElement(value, path, errors); +} + const CHART_TYPES = new Set(["timeseries", "custom"]); const CHART_STYLES = new Set(["line", "bar"]); @@ -1197,6 +1210,64 @@ function validateBlock(value: unknown, path: string, errors: ValidationError[]): } break; } + case "item_list": { + if (!Array.isArray(value.items)) { + errors.push({ + path: `${path}.items`, + message: "Required field 'items' must be an array", + }); + } else { + for (let i = 0; i < value.items.length; i++) { + const item = value.items[i] as unknown; + if (!isRecord(item)) { + errors.push({ path: `${path}.items[${i}]`, message: "Item must be an object" }); + continue; + } + if (typeof item.title !== "string") { + errors.push({ + path: `${path}.items[${i}].title`, + message: "Required field 'title' must be a string", + }); + } + for (const key of ["description", "meta", "badge", "icon", "avatar_url"]) { + if (item[key] !== undefined && typeof item[key] !== "string") { + errors.push({ + path: `${path}.items[${i}].${key}`, + message: `Field '${key}' must be a string if provided`, + }); + } + } + if (item.actions !== undefined) { + if (!Array.isArray(item.actions)) { + errors.push({ + path: `${path}.items[${i}].actions`, + message: "Field 'actions' must be an array if provided", + }); + } else { + for (let j = 0; j < item.actions.length; j++) { + validateButtonAction(item.actions[j], `${path}.items[${i}].actions[${j}]`, errors); + } + } + } + } + } + if ( + value.density !== undefined && + (typeof value.density !== "string" || !ITEM_LIST_DENSITIES.has(value.density)) + ) { + errors.push({ + path: `${path}.density`, + message: `Field 'density' must be one of: ${[...ITEM_LIST_DENSITIES].join(", ")}`, + }); + } + if (value.empty_text !== undefined && typeof value.empty_text !== "string") { + errors.push({ + path: `${path}.empty_text`, + message: "Field 'empty_text' must be a string if provided", + }); + } + break; + } } } diff --git a/packages/blocks/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx index 4c06d65d9a..649a0a86f3 100644 --- a/packages/blocks/tests/renderer.test.tsx +++ b/packages/blocks/tests/renderer.test.tsx @@ -533,6 +533,56 @@ describe("BlockRenderer", () => { expect(onAction).toHaveBeenCalledWith({ type: "block_action", action_id: "ping" }); }); + it("item_list block renders rich list items and forwards actions", () => { + const onAction = vi.fn(); + renderBlocks( + [ + { + type: "item_list", + density: "compact", + items: [ + { + title: "Import queue", + description: "12 posts waiting for review.", + meta: "Updated 2m ago", + badge: "Active", + icon: "IQ", + actions: [{ type: "button", action_id: "open_queue", label: "Open" }], + }, + ], + }, + ], + onAction, + ); + + expect(screen.getByText("Import queue")).toBeTruthy(); + expect(screen.getByText("12 posts waiting for review.")).toBeTruthy(); + expect(screen.getByText("Updated 2m ago")).toBeTruthy(); + expect(screen.getByText("Active")).toBeTruthy(); + expect(screen.getByText("IQ")).toBeTruthy(); + + fireEvent.click(screen.getByText("Open")); + expect(onAction).toHaveBeenCalledWith({ + type: "block_action", + action_id: "open_queue", + }); + }); + + it("item_list block does not render unsafe avatar URLs", () => { + const { container } = renderBlocks([ + { + type: "item_list", + items: [{ title: "Unsafe", avatar_url: "javascript:alert(1)" }], + }, + ]); + expect(container.querySelector("img")).toBeNull(); + }); + + it("item_list block renders empty_text for empty item arrays", () => { + renderBlocks([{ type: "item_list", items: [], empty_text: "No results found" }]); + expect(screen.getByText("No results found")).toBeTruthy(); + }); + it("columns block renders blocks in columns", () => { renderBlocks([ { diff --git a/packages/blocks/tests/validation.test.ts b/packages/blocks/tests/validation.test.ts index e5ac96e162..6f195ba94f 100644 --- a/packages/blocks/tests/validation.test.ts +++ b/packages/blocks/tests/validation.test.ts @@ -135,6 +135,33 @@ describe("validateBlocks", () => { expect(result).toEqual({ valid: true, errors: [] }); }); + it("item_list", () => { + const result = validateBlocks([ + { + type: "item_list", + density: "compact", + empty_text: "No items", + items: [ + { + title: "Import queue", + description: "12 posts waiting for review.", + meta: "Updated 2m ago", + badge: "Active", + icon: "IQ", + avatar_url: "/avatar.png", + actions: [{ type: "button", action_id: "open", label: "Open" }], + }, + ], + }, + ]); + expect(result).toEqual({ valid: true, errors: [] }); + }); + + it("item_list with empty items array", () => { + const result = validateBlocks([{ type: "item_list", items: [] }]); + expect(result).toEqual({ valid: true, errors: [] }); + }); + it("repeater", () => { const result = validateBlocks([ { @@ -501,6 +528,30 @@ describe("validateBlocks", () => { expect(result.errors[0]!.path).toBe("blocks[0].default_open"); }); + it("item_list missing items", () => { + const result = validateBlocks([{ type: "item_list" }]); + expect(result.valid).toBe(false); + expect(result.errors[0]!.path).toBe("blocks[0].items"); + }); + + it("item_list validates item fields", () => { + const result = validateBlocks([ + { + type: "item_list", + items: [{ description: 42, actions: [{ type: "select", action_id: "x", label: "X" }] }], + density: "dense", + empty_text: false, + }, + ]); + expect(result.valid).toBe(false); + const paths = result.errors.map((e) => e.path); + expect(paths).toContain("blocks[0].items[0].title"); + expect(paths).toContain("blocks[0].items[0].description"); + expect(paths).toContain("blocks[0].items[0].actions[0].type"); + expect(paths).toContain("blocks[0].density"); + expect(paths).toContain("blocks[0].empty_text"); + }); + it("stats item missing label or value", () => { const result = validateBlocks([ { From aaeebf9d6c9ec1b17a916bdc916cf0ea0cf28fd1 Mon Sep 17 00:00:00 2001 From: Mason James Date: Sun, 3 May 2026 18:57:15 -0400 Subject: [PATCH 2/2] Format item list block --- packages/blocks/src/blocks/item-list.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/blocks/src/blocks/item-list.tsx b/packages/blocks/src/blocks/item-list.tsx index 3d9b945bd1..057a96d188 100644 --- a/packages/blocks/src/blocks/item-list.tsx +++ b/packages/blocks/src/blocks/item-list.tsx @@ -64,7 +64,9 @@ export function ItemListBlockComponent({ {item.actions && item.actions.length > 0 && (
{item.actions.map((action, actionIndex) => ( -
{renderElement(action, onAction)}
+
+ {renderElement(action, onAction)} +
))}
)}