Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/blockkit-item-list.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions packages/blocks/src/blocks/item-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 <p className="py-4 text-center text-sm text-kumo-subtle">{block.empty_text}</p>;
}

const compact = block.density === "compact";

return (
<div className="divide-y divide-kumo-line rounded-md border border-kumo-line bg-kumo-surface">
{block.items.map((item, i) => {
const canPreviewAvatar = item.avatar_url ? isSafePreviewUrl(item.avatar_url) : false;
return (
<div
key={`${item.title}-${i}`}
className={cn("flex items-start gap-3", compact ? "p-3" : "p-4")}
>
{canPreviewAvatar ? (
<img
src={item.avatar_url}
alt=""
className="h-9 w-9 shrink-0 rounded-md object-cover"
referrerPolicy="no-referrer"
loading="lazy"
/>
) : item.icon ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-kumo-muted text-xs font-medium text-kumo-subtle">
{item.icon}
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-kumo-default">{item.title}</h3>
{item.badge && <Badge>{item.badge}</Badge>}
{item.meta && <span className="text-xs text-kumo-subtle">{item.meta}</span>}
</div>
{item.description && (
<p className={cn("text-sm text-kumo-subtle", compact ? "mt-0.5" : "mt-1")}>
{item.description}
</p>
)}
{item.actions && item.actions.length > 0 && (
<div className={cn("flex flex-wrap gap-2", compact ? "mt-2" : "mt-3")}>
{item.actions.map((action, actionIndex) => (
<div key={action.action_id ?? actionIndex}>
{renderElement(action, onAction)}
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
);
}
18 changes: 18 additions & 0 deletions packages/blocks/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type {
FormField,
HeaderBlock,
ImageBlock,
ItemListBlock,
ListItem,
MediaPickerElement,
MeterBlock,
NumberInputElement,
Expand Down Expand Up @@ -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 = {
Expand All @@ -517,6 +534,7 @@ export const blocks = {
tab: tabBlock,
empty,
accordion,
itemList,
};

export const elements = {
Expand Down
2 changes: 2 additions & 0 deletions packages/blocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
// Block sub-types
TableColumn,
StatItem,
ListItem,
ChartSeries,
ChartConfig,
TimeseriesChartConfig,
Expand All @@ -56,6 +57,7 @@ export type {
MeterBlock,
EmptyBlock,
AccordionBlock,
ItemListBlock,
Block,
// Interactions
BlockAction,
Expand Down
3 changes: 3 additions & 0 deletions packages/blocks/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,6 +60,8 @@ function renderBlock(
return <EmptyBlockComponent block={block} onAction={onAction} />;
case "accordion":
return <AccordionBlockComponent block={block} onAction={onAction} />;
case "item_list":
return <ItemListBlockComponent block={block} onAction={onAction} />;
default: {
const _exhaustive: never = block;
return null;
Expand Down
20 changes: 19 additions & 1 deletion packages/blocks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand All @@ -382,7 +399,8 @@ export type Block =
| CodeBlock
| TabBlock
| EmptyBlock
| AccordionBlock;
| AccordionBlock
| ItemListBlock;

// ── Interactions ─────────────────────────────────────────────────────────────

Expand Down
71 changes: 71 additions & 0 deletions packages/blocks/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const BLOCK_TYPES = new Set([
"code",
"empty",
"accordion",
"item_list",
]);

const EMPTY_SIZES = new Set(["sm", "base", "lg"]);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"]);

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

Expand Down
50 changes: 50 additions & 0 deletions packages/blocks/tests/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Loading
Loading