[0]) =>
+ intl.formatMessage(uiPatternsMessage(id));
+ const descending = hasSearchQuery || direction === "desc";
+ return (
+
+ onFieldChange(e.target.value as ProjectSortField)}
+ aria-label={message("ui-patterns.sort-projects-label")}
+ // The adjacent direction button keeps the control from reading as a
+ // plain text field.
+ hideChevron
+ disabled={hasSearchQuery}
+ // Lets the select shrink below its longest option when space is
+ // tight, clipping the text.
+ css={{ fontSize: "lg", background: "white", flex: 1, minW: 0 }}
+ >
+ {hasSearchQuery ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/ui-patterns/src/projects/index.ts b/packages/ui-patterns/src/projects/index.ts
new file mode 100644
index 0000000..ec795f4
--- /dev/null
+++ b/packages/ui-patterns/src/projects/index.ts
@@ -0,0 +1,14 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+export * from "./NameProjectDialog";
+export * from "./ProjectCard";
+export * from "./ProjectsToolbar";
+export * from "./SearchInput";
+export * from "./SortInput";
+export * from "./project-list";
+export * from "./time-ago";
+export * from "./types";
+export * from "./useProjectActions";
diff --git a/packages/ui-patterns/src/projects/project-list.ts b/packages/ui-patterns/src/projects/project-list.ts
new file mode 100644
index 0000000..468c7f2
--- /dev/null
+++ b/packages/ui-patterns/src/projects/project-list.ts
@@ -0,0 +1,148 @@
+/**
+ * List state shared by the projects pages: ranking a search, sorting, and
+ * a multi-selection that forgets projects that disappear.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { useCallback, useMemo, useReducer } from "react";
+import { ProjectSortField, ProjectSummary, SortDirection } from "./types";
+
+/**
+ * Ranks projects against a search query. Every term must match the name or
+ * one of the app's secondary terms (file names, action names). Name matches
+ * outrank secondary matches; exact beats prefix beats substring. Projects
+ * with no match are dropped.
+ */
+export const rankProjects = (
+ projects: P[],
+ query: string,
+ secondaryTerms: (project: P) => string[] = () => [],
+): P[] => {
+ const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
+ if (terms.length === 0) {
+ return projects;
+ }
+ const ranked: Array<{ project: P; score: number }> = [];
+ for (const project of projects) {
+ const name = project.name.toLowerCase();
+ const secondary = secondaryTerms(project).map((t) => t.toLowerCase());
+ const allMatch = terms.every(
+ (term) => name.includes(term) || secondary.some((s) => s.includes(term)),
+ );
+ if (!allMatch) {
+ continue;
+ }
+ let score = 0;
+ for (const term of terms) {
+ if (name === term) {
+ score += 100;
+ } else if (name.startsWith(term)) {
+ score += 50;
+ } else if (name.includes(term)) {
+ score += 30;
+ }
+ for (const s of secondary) {
+ if (s === term) {
+ score += 15;
+ } else if (s.startsWith(term)) {
+ score += 8;
+ } else if (s.includes(term)) {
+ score += 5;
+ }
+ }
+ }
+ ranked.push({ project, score });
+ }
+ return ranked.sort((a, b) => b.score - a.score).map((r) => r.project);
+};
+
+export const sortProjects =
(
+ projects: P[],
+ field: ProjectSortField,
+ direction: SortDirection,
+): P[] => {
+ const sorted = [...projects].sort((a, b) =>
+ field === "name"
+ ? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+ : a.timestamp - b.timestamp,
+ );
+ return direction === "desc" ? sorted.reverse() : sorted;
+};
+
+/** The default direction when switching to a field: newest first, A to Z. */
+export const defaultSortDirection = (field: ProjectSortField): SortDirection =>
+ field === "name" ? "asc" : "desc";
+
+export interface ProjectSelection {
+ selectedIds: string[];
+ hasSelection: boolean;
+ /**
+ * The selection as it was when last non-empty. For a toolbar that slides
+ * out on clearing, so it does not change shape mid-animation.
+ */
+ lastSelectedIds: string[];
+ isSelected: (id: string) => boolean;
+ toggle: (id: string) => void;
+ clear: () => void;
+}
+
+interface SelectionState {
+ selected: string[];
+ last: string[];
+}
+
+type SelectionAction = { type: "toggle"; id: string } | { type: "clear" };
+
+const selectionReducer = (
+ state: SelectionState,
+ action: SelectionAction,
+): SelectionState => {
+ switch (action.type) {
+ case "toggle": {
+ const selected = state.selected.includes(action.id)
+ ? state.selected.filter((v) => v !== action.id)
+ : [...state.selected, action.id];
+ return { selected, last: selected.length > 0 ? selected : state.last };
+ }
+ case "clear":
+ return { selected: [], last: state.last };
+ }
+};
+
+const noSelection: SelectionState = { selected: [], last: [] };
+
+/**
+ * Multi-selection of projects by id. A project that leaves the list (deleted,
+ * perhaps in another tab) leaves the selection too.
+ */
+export const useProjectSelection = (
+ projects: ProjectSummary[],
+): ProjectSelection => {
+ const [state, dispatch] = useReducer(selectionReducer, noSelection);
+ const selectedIds = useMemo(() => {
+ const ids = new Set(projects.map((p) => p.id));
+ return state.selected.filter((id) => ids.has(id));
+ }, [projects, state.selected]);
+ const toggle = useCallback(
+ (id: string) => dispatch({ type: "toggle", id }),
+ [],
+ );
+ const clear = useCallback(() => dispatch({ type: "clear" }), []);
+ const isSelected = useCallback(
+ (id: string) => selectedIds.includes(id),
+ [selectedIds],
+ );
+ return useMemo(
+ () => ({
+ selectedIds,
+ hasSelection: selectedIds.length > 0,
+ lastSelectedIds: state.last,
+ isSelected,
+ toggle,
+ clear,
+ }),
+ [selectedIds, state.last, isSelected, toggle, clear],
+ );
+};
diff --git a/packages/ui-patterns/src/projects/time-ago.ts b/packages/ui-patterns/src/projects/time-ago.ts
new file mode 100644
index 0000000..a5c14d6
--- /dev/null
+++ b/packages/ui-patterns/src/projects/time-ago.ts
@@ -0,0 +1,40 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { IntlShape } from "react-intl";
+import { uiPatternsMessage } from "../messages";
+
+const units: Array<{ unit: Intl.RelativeTimeFormatUnit; seconds: number }> = [
+ { unit: "year", seconds: 31536000 },
+ { unit: "month", seconds: 2592000 },
+ { unit: "day", seconds: 86400 },
+ { unit: "hour", seconds: 3600 },
+ { unit: "minute", seconds: 60 },
+ { unit: "second", seconds: 1 },
+];
+
+/**
+ * "3 days ago", in the intl locale. Sub-minute times say "a few seconds ago"
+ * rather than counting, since the card does not re-render every second.
+ */
+export const formatTimeAgo = (
+ intl: IntlShape,
+ timestamp: number,
+ now: number = Date.now(),
+): string => {
+ const diffInSeconds = (timestamp - now) / 1000;
+ for (const { unit, seconds } of units) {
+ const interval = Math.round(diffInSeconds / seconds);
+ if (Math.abs(interval) >= 1) {
+ if (unit === "second") {
+ return intl.formatMessage(
+ uiPatternsMessage("ui-patterns.timestamp-seconds"),
+ );
+ }
+ return intl.formatRelativeTime(interval, unit);
+ }
+ }
+ return intl.formatMessage(uiPatternsMessage("ui-patterns.timestamp-now"));
+};
diff --git a/packages/ui-patterns/src/projects/types.ts b/packages/ui-patterns/src/projects/types.ts
new file mode 100644
index 0000000..c487d05
--- /dev/null
+++ b/packages/ui-patterns/src/projects/types.ts
@@ -0,0 +1,20 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * What the project components need to know about a project. Apps keep their
+ * own richer records and pass these, or a superset.
+ */
+export interface ProjectSummary {
+ id: string;
+ name: string;
+ /** Last modified or opened, in milliseconds since the epoch. */
+ timestamp: number;
+}
+
+export type ProjectSortField = "name" | "timestamp";
+
+export type SortDirection = "asc" | "desc";
diff --git a/packages/ui-patterns/src/projects/useProjectActions.tsx b/packages/ui-patterns/src/projects/useProjectActions.tsx
new file mode 100644
index 0000000..1af8d50
--- /dev/null
+++ b/packages/ui-patterns/src/projects/useProjectActions.tsx
@@ -0,0 +1,208 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ConfirmDialog, Text } from "@microbit/ui";
+import { ReactNode, useCallback, useMemo, useState } from "react";
+import { FormattedMessage } from "react-intl";
+import { uiPatternsMessage } from "../messages";
+import { NameProjectDialog } from "./NameProjectDialog";
+import { ProjectSummary } from "./types";
+
+type NameDialogReason = "rename" | "duplicate";
+
+export interface UseProjectActionsOptions {
+ projects: ProjectSummary[];
+ onRename: (id: string, name: string) => void | Promise;
+ onDuplicate: (id: string, name: string) => void | Promise;
+ /** One id from a card's menu, or the selection from the toolbar. */
+ onDelete: (ids: string[]) => void | Promise;
+ /**
+ * The current selection, for toolbar actions that name no project. Rename
+ * and duplicate act only when exactly one project is selected.
+ */
+ getSelectedIds?: () => string[];
+}
+
+/**
+ * Each action takes the id of the project, or acts on the selection without
+ * one, and optionally the element that started it, which the dialog returns
+ * focus to on closing. ProjectCard passes its menu button.
+ */
+type ProjectAction = (id?: string, trigger?: HTMLElement) => void;
+
+export interface ProjectActions {
+ /**
+ * The name and confirm dialogs, wired up. Render once on the page.
+ */
+ dialogs: ReactNode;
+ /** Opens the name dialog. Needs exactly one project. */
+ rename: ProjectAction;
+ /** Opens the name dialog for the copy. Needs exactly one project. */
+ duplicate: ProjectAction;
+ /** Opens the confirm dialog for one project or the selection. */
+ requestDelete: ProjectAction;
+}
+
+/**
+ * The rename, duplicate and delete flows behind ProjectCard menus and the
+ * ProjectsToolbar: which project is being acted on, the dialogs, and focus
+ * return. The app supplies what each action does and logs what it wants.
+ */
+export const useProjectActions = ({
+ projects,
+ onRename,
+ onDuplicate,
+ onDelete,
+ getSelectedIds,
+}: UseProjectActionsOptions): ProjectActions => {
+ const [target, setTarget] = useState();
+ const [nameReason, setNameReason] = useState();
+ const [confirming, setConfirming] = useState(false);
+ const [trigger, setTrigger] = useState();
+ const finalFocusRef = useMemo(
+ () => ({ current: trigger ?? null }),
+ [trigger],
+ );
+ const clearFinalFocusRef = useCallback(() => setTrigger(undefined), []);
+
+ const resolve = useCallback(
+ (id?: string): ProjectSummary | undefined => {
+ const selected = getSelectedIds?.() ?? [];
+ const resolvedId =
+ id ?? (selected.length === 1 ? selected[0] : undefined);
+ return projects.find((p) => p.id === resolvedId);
+ },
+ [getSelectedIds, projects],
+ );
+
+ const openNameDialog = useCallback(
+ (reason: NameDialogReason, id?: string, trigger?: HTMLElement) => {
+ const project = resolve(id);
+ if (project) {
+ setTarget(project);
+ setTrigger(trigger);
+ setNameReason(reason);
+ }
+ },
+ [resolve],
+ );
+ const rename = useCallback(
+ (id, trigger) => openNameDialog("rename", id, trigger),
+ [openNameDialog],
+ );
+ const duplicate = useCallback(
+ (id, trigger) => openNameDialog("duplicate", id, trigger),
+ [openNameDialog],
+ );
+ const closeNameDialog = useCallback(() => setNameReason(undefined), []);
+ const saveName = useCallback(
+ async (name: string) => {
+ const project = target;
+ const reason = nameReason;
+ closeNameDialog();
+ if (project && reason) {
+ await (reason === "rename"
+ ? onRename(project.id, name)
+ : onDuplicate(project.id, name));
+ }
+ },
+ [closeNameDialog, nameReason, onDuplicate, onRename, target],
+ );
+
+ const requestDelete = useCallback(
+ (id, trigger) => {
+ const project = id ? resolve(id) : undefined;
+ setTarget(project);
+ setTrigger(trigger);
+ if (project || (getSelectedIds?.().length ?? 0) > 0) {
+ setConfirming(true);
+ }
+ },
+ [getSelectedIds, resolve],
+ );
+ const closeConfirm = useCallback(() => setConfirming(false), []);
+ const confirmDelete = useCallback(async () => {
+ const ids = target ? [target.id] : getSelectedIds?.() ?? [];
+ closeConfirm();
+ if (ids.length > 0) {
+ await onDelete(ids);
+ }
+ }, [closeConfirm, getSelectedIds, onDelete, target]);
+
+ const selectedCount = getSelectedIds?.().length ?? 0;
+ const dialogs = (
+ <>
+
+ }
+ confirmText={
+
+ }
+ />
+
+ }
+ body={
+
+ {target ? (
+
+ ) : (
+
+ )}
+
+ }
+ confirmText={
+
+ }
+ onConfirm={confirmDelete}
+ onCancel={closeConfirm}
+ onCloseComplete={clearFinalFocusRef}
+ finalFocusRef={finalFocusRef}
+ />
+ >
+ );
+
+ return { dialogs, rename, duplicate, requestDelete };
+};
diff --git a/packages/ui-patterns/stories/NameProjectDialog.stories.tsx b/packages/ui-patterns/stories/NameProjectDialog.stories.tsx
new file mode 100644
index 0000000..f96739c
--- /dev/null
+++ b/packages/ui-patterns/stories/NameProjectDialog.stories.tsx
@@ -0,0 +1,81 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box, Button, useDisclosure } from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ReactNode } from "react";
+import { NameProjectDialog, NameProjectDialogProps } from "../src";
+
+const meta = {
+ title: "Projects/NameProjectDialog",
+ component: NameProjectDialog,
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+type HarnessProps = Omit<
+ NameProjectDialogProps,
+ "isOpen" | "onClose" | "onSave" | "confirmText"
+> &
+ Partial>;
+
+const Harness = (props: HarnessProps) => {
+ const disclosure = useDisclosure();
+ return (
+
+
+ {
+ alert(`saved: ${name}`);
+ disclosure.onClose();
+ }}
+ />
+
+ );
+};
+
+const args = {
+ isOpen: true,
+ onClose: () => {},
+ onSave: () => {},
+ initialName: "Untitled project",
+ confirmText: "Create",
+};
+
+/** Naming a new project: the default heading. */
+export const NewProject: Story = {
+ args,
+ render: ({ initialName }) => ,
+};
+
+/** With app-specific helper text under the field. */
+export const WithHelperText: Story = {
+ args,
+ render: ({ initialName }) => (
+
+ ),
+};
+
+/** Renaming: the app passes the heading and confirm text. */
+export const Rename: Story = {
+ args: { ...args, initialName: "Heartbeat monitor" },
+ render: ({ initialName }) => (
+
+ ),
+};
diff --git a/packages/ui-patterns/stories/ProjectCard.stories.tsx b/packages/ui-patterns/stories/ProjectCard.stories.tsx
new file mode 100644
index 0000000..9841d11
--- /dev/null
+++ b/packages/ui-patterns/stories/ProjectCard.stories.tsx
@@ -0,0 +1,106 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box, css, Grid } from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ProjectCard } from "../src";
+
+const meta = {
+ title: "Projects/ProjectCard",
+ component: ProjectCard,
+ args: {
+ project: {
+ id: "p1",
+ name: "Heartbeat monitor",
+ timestamp: Date.now() - 3 * 3_600_000,
+ },
+ onOpen: (id) => alert(`open ${id}`),
+ onDelete: (id) => alert(`delete ${id}`),
+ onRename: (id) => alert(`rename ${id}`),
+ onDuplicate: (id) => alert(`duplicate ${id}`),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+/** Stands in for an app's logo or glyph. */
+const Glyph = () => (
+
+);
+
+export const Default: Story = {
+ args: { children: },
+};
+
+/** A line under the name for what the project contains. */
+export const WithDescription: Story = {
+ args: { children: , description: "main.py, sensors.py" },
+};
+
+/** As on a projects page: selectable, with the hidden skip-to-toolbar link. */
+export const Selectable: Story = {
+ args: {
+ children: ,
+ isSelected: true,
+ onSelected: (id) => alert(`toggle ${id}`),
+ onSkipToToolbar: () => alert("skip to toolbar"),
+ },
+};
+
+export const LongName: Story = {
+ args: {
+ children: ,
+ project: {
+ id: "p2",
+ name: "A project with a name far too long to fit on the card",
+ timestamp: Date.now() - 40 * 86_400_000,
+ },
+ },
+};
+
+/** Equal heights in a grid, as the projects page lays them out. */
+export const InAGrid: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ render: (args) => (
+
+ {["Heart", "Radio messenger", "Step counter", "Night light"].map(
+ (name, i) => (
+
+
+
+
+
+ ),
+ )}
+
+ ),
+};
diff --git a/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx b/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx
new file mode 100644
index 0000000..348dd1a
--- /dev/null
+++ b/packages/ui-patterns/stories/ProjectsPageExample.stories.tsx
@@ -0,0 +1,247 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ Box,
+ css,
+ cx,
+ Flex,
+ Grid,
+ HStack,
+ Slide,
+ Text,
+ useBreakpointValue,
+ VStack,
+} from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useMemo, useState } from "react";
+import {
+ defaultSortDirection,
+ ProjectCard,
+ ProjectSortField,
+ ProjectsToolbar,
+ rankProjects,
+ SearchInput,
+ SortDirection,
+ SortInput,
+ sortProjects,
+ useProjectActions,
+ useProjectSelection,
+} from "../src";
+
+interface ExampleProject {
+ id: string;
+ name: string;
+ timestamp: number;
+ files: string[];
+}
+
+const day = 86_400_000;
+const initialProjects: ExampleProject[] = [
+ {
+ id: "1",
+ name: "Heart",
+ timestamp: Date.now() - 2 * 3_600_000,
+ files: ["main.py"],
+ },
+ {
+ id: "2",
+ name: "Heartbeat monitor",
+ timestamp: Date.now() - day,
+ files: ["main.py", "sensor.py"],
+ },
+ {
+ id: "3",
+ name: "Radio messenger",
+ timestamp: Date.now() - 3 * day,
+ files: ["main.py", "radio.py"],
+ },
+ {
+ id: "4",
+ name: "Step counter",
+ timestamp: Date.now() - 12 * day,
+ files: ["main.py"],
+ },
+ {
+ id: "5",
+ name: "Night light",
+ timestamp: Date.now() - 40 * day,
+ files: ["main.py", "light.py"],
+ },
+];
+
+const Glyph = () => (
+
+);
+
+/**
+ * How an app composes the pieces into a projects page. Everything here is
+ * app-side: the page layout, the grid, where the toolbar goes at each
+ * width, and what rename, duplicate and delete actually do.
+ */
+const ProjectsPageExample = () => {
+ const [projects, setProjects] = useState(initialProjects);
+ const [query, setQuery] = useState("");
+ const [field, setField] = useState("timestamp");
+ const [direction, setDirection] = useState("desc");
+ const selection = useProjectSelection(projects);
+ const mobileIconOnly = useBreakpointValue({ base: true, md: false });
+
+ const actions = useProjectActions({
+ projects,
+ getSelectedIds: () => selection.selectedIds,
+ onRename: (id, name) =>
+ setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, name } : p))),
+ onDuplicate: (id, name) =>
+ setProjects((ps) => {
+ const source = ps.find((p) => p.id === id)!;
+ return [
+ ...ps,
+ { ...source, id: String(Date.now()), name, timestamp: Date.now() },
+ ];
+ }),
+ onDelete: (ids) =>
+ setProjects((ps) => ps.filter((p) => !ids.includes(p.id))),
+ });
+
+ const shown = useMemo(
+ () =>
+ query.trim()
+ ? rankProjects(projects, query, (p) => p.files)
+ : sortProjects(projects, field, direction),
+ [direction, field, projects, query],
+ );
+
+ return (
+ <>
+ {actions.dialogs}
+
+
+
+ {
+ if (value.trim()) {
+ selection.clear();
+ }
+ setQuery(value);
+ }}
+ className={css({ maxW: "30ch" })}
+ />
+ {selection.hasSelection && (
+
+
+
+ )}
+ {
+ setField(next);
+ setDirection(defaultSortDirection(next));
+ }}
+ onToggleDirection={() =>
+ setDirection((d) => (d === "asc" ? "desc" : "asc"))
+ }
+ hasSearchQuery={!!query.trim()}
+ />
+
+ {shown.length > 0 ? (
+
+ {shown.map((project) => (
+
+ alert(`open ${id}`)}
+ onDelete={actions.requestDelete}
+ onRename={actions.rename}
+ onDuplicate={actions.duplicate}
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ No projects to display
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const meta = {
+ title: "Projects/Page example",
+ component: ProjectsPageExample,
+ parameters: { layout: "fullscreen" },
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx b/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx
new file mode 100644
index 0000000..dd60518
--- /dev/null
+++ b/packages/ui-patterns/stories/ProjectsToolbar.stories.tsx
@@ -0,0 +1,49 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box } from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ProjectsToolbar } from "../src";
+
+const meta = {
+ title: "Projects/ProjectsToolbar",
+ component: ProjectsToolbar,
+ args: {
+ selectedCount: 1,
+ onRename: () => alert("rename"),
+ onDuplicate: () => alert("duplicate"),
+ onDelete: () => alert("delete"),
+ onClearSelection: () => alert("clear"),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+/** One project selected: rename and duplicate are offered. */
+export const SingleSelection: Story = {};
+
+/** Several selected: only delete (with the count) and clear. */
+export const MultipleSelection: Story = {
+ args: { selectedCount: 3 },
+};
+
+/** The narrow layout: icons only, detached, larger targets. */
+export const IconOnly: Story = {
+ args: { iconOnly: true, isAttached: false, size: "lg" },
+};
diff --git a/packages/ui-patterns/stories/SearchInput.stories.tsx b/packages/ui-patterns/stories/SearchInput.stories.tsx
new file mode 100644
index 0000000..dfbe61c
--- /dev/null
+++ b/packages/ui-patterns/stories/SearchInput.stories.tsx
@@ -0,0 +1,40 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box } from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { SearchInput } from "../src";
+
+const meta = {
+ title: "Projects/SearchInput",
+ component: SearchInput,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+const Controlled = ({ initial }: { initial: string }) => {
+ const [value, setValue] = useState(initial);
+ return ;
+};
+
+export const Empty: Story = {
+ args: { value: "", onChange: () => {} },
+ render: () => ,
+};
+
+/** With text the clear button appears; clearing returns focus to the box. */
+export const WithText: Story = {
+ args: { value: "heart", onChange: () => {} },
+ render: () => ,
+};
diff --git a/packages/ui-patterns/stories/SortInput.stories.tsx b/packages/ui-patterns/stories/SortInput.stories.tsx
new file mode 100644
index 0000000..5718d61
--- /dev/null
+++ b/packages/ui-patterns/stories/SortInput.stories.tsx
@@ -0,0 +1,67 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box } from "@microbit/ui";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import {
+ defaultSortDirection,
+ ProjectSortField,
+ SortDirection,
+ SortInput,
+} from "../src";
+
+const meta = {
+ title: "Projects/SortInput",
+ component: SortInput,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+const Controlled = ({ hasSearchQuery }: { hasSearchQuery: boolean }) => {
+ const [field, setField] = useState("timestamp");
+ const [direction, setDirection] = useState("desc");
+ return (
+ {
+ setField(next);
+ setDirection(defaultSortDirection(next));
+ }}
+ onToggleDirection={() =>
+ setDirection((d) => (d === "asc" ? "desc" : "asc"))
+ }
+ hasSearchQuery={hasSearchQuery}
+ />
+ );
+};
+
+const args = {
+ field: "timestamp" as const,
+ direction: "desc" as const,
+ onFieldChange: () => {},
+ onToggleDirection: () => {},
+ hasSearchQuery: false,
+};
+
+export const Default: Story = {
+ args,
+ render: () => ,
+};
+
+/** While searching the list is ranked by relevance and the controls rest. */
+export const WhileSearching: Story = {
+ args: { ...args, hasSearchQuery: true },
+ render: () => ,
+};
diff --git a/packages/ui-patterns/tests/ProjectCard.test.tsx b/packages/ui-patterns/tests/ProjectCard.test.tsx
new file mode 100644
index 0000000..2f7c800
--- /dev/null
+++ b/packages/ui-patterns/tests/ProjectCard.test.tsx
@@ -0,0 +1,244 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SharedUIProvider } from "@microbit/ui";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ReactNode } from "react";
+import { IntlProvider } from "react-intl";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ ProjectCard,
+ ProjectsToolbar,
+ useProjectActions,
+ useProjectSelection,
+} from "../src";
+
+afterEach(cleanup);
+
+const Providers = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+const project = { id: "p1", name: "Heart", timestamp: Date.now() - 120_000 };
+
+describe("ProjectCard", () => {
+ it("shows the name, content, description and age, and opens by name", async () => {
+ const user = userEvent.setup();
+ const onOpen = vi.fn();
+ render(
+ {}}
+ onRename={() => {}}
+ onDuplicate={() => {}}
+ >
+
+ ,
+ { wrapper: Providers },
+ );
+ expect(screen.getByTestId("glyph")).toBeDefined();
+ expect(screen.getByText("main.py, helper.py")).toBeDefined();
+ const time = screen.getByText("2 minutes ago");
+ expect(time.tagName).toBe("TIME");
+ expect(time.getAttribute("dateTime")).toBe(
+ new Date(project.timestamp).toISOString(),
+ );
+ expect(time.getAttribute("title")).toMatch(/\d/);
+ await user.click(screen.getByRole("button", { name: "Heart" }));
+ expect(onOpen).toHaveBeenCalledWith("p1");
+ });
+
+ it("offers open, rename, duplicate and delete from its menu, passing the menu button", async () => {
+ const user = userEvent.setup();
+ const onOpen = vi.fn();
+ const onRename = vi.fn();
+ const onDuplicate = vi.fn();
+ const onDelete = vi.fn();
+ render(
+ ,
+ { wrapper: Providers },
+ );
+ const menuButton = screen.getByRole("button", {
+ name: "Heart actions menu",
+ });
+ await user.click(menuButton);
+ await user.click(screen.getByRole("menuitem", { name: "Duplicate" }));
+ expect(onDuplicate).toHaveBeenCalledWith("p1", menuButton);
+
+ await user.click(menuButton);
+ await user.click(screen.getByRole("menuitem", { name: "Rename" }));
+ expect(onRename).toHaveBeenCalledWith("p1", menuButton);
+
+ await user.click(menuButton);
+ await user.click(screen.getByRole("menuitem", { name: "Delete" }));
+ expect(onDelete).toHaveBeenCalledWith("p1", menuButton);
+
+ await user.click(menuButton);
+ await user.click(screen.getByRole("menuitem", { name: "Open" }));
+ expect(onOpen).toHaveBeenCalledWith("p1");
+ });
+
+ it("shows a selection checkbox and skip link only when selectable", async () => {
+ const user = userEvent.setup();
+ const onSelected = vi.fn();
+ const onSkipToToolbar = vi.fn();
+ const { rerender } = render(
+ {}}
+ onDelete={() => {}}
+ onRename={() => {}}
+ onDuplicate={() => {}}
+ />,
+ { wrapper: Providers },
+ );
+ expect(screen.queryByRole("checkbox")).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Skip to toolbar" }),
+ ).toBeNull();
+
+ rerender(
+ {}}
+ onDelete={() => {}}
+ onRename={() => {}}
+ onDuplicate={() => {}}
+ />,
+ );
+ await user.click(screen.getByRole("checkbox", { name: "Select Heart" }));
+ expect(onSelected).toHaveBeenCalledWith("p1");
+ await user.click(screen.getByRole("button", { name: "Skip to toolbar" }));
+ expect(onSkipToToolbar).toHaveBeenCalledOnce();
+ });
+});
+
+const projects = [
+ { id: "a", name: "Alpha", timestamp: 1 },
+ { id: "b", name: "Beta", timestamp: 2 },
+];
+
+const Harness = ({
+ onRename,
+ onDuplicate,
+ onDelete,
+}: {
+ onRename: (id: string, name: string) => void;
+ onDuplicate: (id: string, name: string) => void;
+ onDelete: (ids: string[]) => void;
+}) => {
+ const selection = useProjectSelection(projects);
+ const actions = useProjectActions({
+ projects,
+ onRename,
+ onDuplicate,
+ onDelete,
+ getSelectedIds: () => selection.selectedIds,
+ });
+ return (
+ <>
+ {actions.dialogs}
+ {selection.hasSelection && (
+
+ )}
+ {projects.map((p) => (
+ {}}
+ onDelete={actions.requestDelete}
+ onRename={actions.rename}
+ onDuplicate={actions.duplicate}
+ />
+ ))}
+ >
+ );
+};
+
+describe("useProjectActions", () => {
+ it("renames from a card menu via the name dialog", async () => {
+ const user = userEvent.setup();
+ const onRename = vi.fn();
+ render(
+ ,
+ {
+ wrapper: Providers,
+ },
+ );
+ await user.click(
+ screen.getByRole("button", { name: "Alpha actions menu" }),
+ );
+ await user.click(screen.getByRole("menuitem", { name: "Rename" }));
+ const dialog = within(screen.getByRole("dialog"));
+ expect(
+ dialog.getByRole("heading", { name: "Rename project" }),
+ ).toBeDefined();
+ const field = dialog.getByRole("textbox", { name: /Name/ });
+ expect((field as HTMLInputElement).value).toEqual("Alpha");
+ await user.clear(field);
+ await user.type(field, "Alpha 2");
+ await user.click(dialog.getByRole("button", { name: "Rename" }));
+ expect(onRename).toHaveBeenCalledWith("a", "Alpha 2");
+ });
+
+ it("deletes the selection from the toolbar after confirmation", async () => {
+ const user = userEvent.setup();
+ const onDelete = vi.fn();
+ render(
+ ,
+ {
+ wrapper: Providers,
+ },
+ );
+ await user.click(screen.getByRole("checkbox", { name: "Select Alpha" }));
+ await user.click(screen.getByRole("checkbox", { name: "Select Beta" }));
+ await user.click(screen.getByRole("button", { name: "Delete 2 projects" }));
+ const alert = within(screen.getByRole("alertdialog"));
+ expect(
+ alert.getByText("Are you sure you want to delete 2 projects?"),
+ ).toBeDefined();
+ await user.click(alert.getByRole("button", { name: "Delete 2 projects" }));
+ expect(onDelete).toHaveBeenCalledWith(["a", "b"]);
+ });
+
+ it("deletes one project from its menu, naming it in the confirmation", async () => {
+ const user = userEvent.setup();
+ const onDelete = vi.fn();
+ render(
+ ,
+ {
+ wrapper: Providers,
+ },
+ );
+ await user.click(screen.getByRole("button", { name: "Beta actions menu" }));
+ await user.click(screen.getByRole("menuitem", { name: "Delete" }));
+ const alert = within(screen.getByRole("alertdialog"));
+ expect(alert.getByText(/delete the project "Beta"/)).toBeDefined();
+ await user.click(alert.getByRole("button", { name: "Delete" }));
+ expect(onDelete).toHaveBeenCalledWith(["b"]);
+ });
+});
diff --git a/packages/ui-patterns/tests/project-controls.test.tsx b/packages/ui-patterns/tests/project-controls.test.tsx
new file mode 100644
index 0000000..d0bef1f
--- /dev/null
+++ b/packages/ui-patterns/tests/project-controls.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SharedUIProvider } from "@microbit/ui";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ReactNode } from "react";
+import { IntlProvider } from "react-intl";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ProjectsToolbar, SearchInput, SortInput } from "../src";
+
+afterEach(cleanup);
+
+const Providers = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+describe("SearchInput", () => {
+ it("reports typing and clears back to the box", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const { rerender } = render(, {
+ wrapper: Providers,
+ });
+ expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
+ await user.type(screen.getByRole("searchbox", { name: "Search" }), "h");
+ expect(onChange).toHaveBeenLastCalledWith("h");
+
+ rerender();
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+ expect(onChange).toHaveBeenLastCalledWith("");
+ expect(document.activeElement).toBe(
+ screen.getByRole("searchbox", { name: "Search" }),
+ );
+ });
+});
+
+describe("SortInput", () => {
+ it("offers name and last modified with a direction toggle", async () => {
+ const user = userEvent.setup();
+ const onFieldChange = vi.fn();
+ const onToggleDirection = vi.fn();
+ render(
+ ,
+ { wrapper: Providers },
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Sort projects" }),
+ "name",
+ );
+ expect(onFieldChange).toHaveBeenCalledWith("name");
+ await user.click(screen.getByRole("button", { name: "Descending order" }));
+ expect(onToggleDirection).toHaveBeenCalledOnce();
+ });
+
+ it("shows relevance and disables itself while searching", () => {
+ render(
+ {}}
+ onToggleDirection={() => {}}
+ hasSearchQuery
+ />,
+ { wrapper: Providers },
+ );
+ const select = screen.getByRole("combobox", {
+ name: "Sort projects",
+ });
+ expect(select.disabled).toBe(true);
+ expect(select.value).toEqual("relevance");
+ expect(
+ screen.getByRole("button", {
+ name: "Descending order",
+ }).disabled,
+ ).toBe(true);
+ });
+});
+
+describe("ProjectsToolbar", () => {
+ it("offers rename and duplicate only for a single selection", () => {
+ const { rerender } = render(
+ {}}
+ onDuplicate={() => {}}
+ onDelete={() => {}}
+ onClearSelection={() => {}}
+ />,
+ { wrapper: Providers },
+ );
+ const group = screen.getByRole("group", { name: "Selection actions" });
+ expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
+ "Rename",
+ "Duplicate",
+ "Delete",
+ "Clear",
+ ]);
+ expect(group).toBeDefined();
+
+ rerender(
+ {}}
+ onDuplicate={() => {}}
+ onDelete={() => {}}
+ onClearSelection={() => {}}
+ />,
+ );
+ expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual([
+ "Delete 3 projects",
+ "Clear",
+ ]);
+ });
+
+ it("calls back and keeps accessible names when icon-only", async () => {
+ const user = userEvent.setup();
+ const onRename = vi.fn();
+ const onDuplicate = vi.fn();
+ const onDelete = vi.fn();
+ const onClearSelection = vi.fn();
+ render(
+ ,
+ { wrapper: Providers },
+ );
+ await user.click(screen.getByRole("button", { name: "Duplicate" }));
+ expect(onDuplicate).toHaveBeenCalledOnce();
+ // No arguments: the hook treats a first argument as a project id.
+ expect(onDuplicate).toHaveBeenCalledWith();
+ await user.click(screen.getByRole("button", { name: "Rename" }));
+ expect(onRename).toHaveBeenCalledWith();
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ expect(onDelete).toHaveBeenCalledOnce();
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+ expect(onClearSelection).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/ui-patterns/tests/project-dialogs.test.tsx b/packages/ui-patterns/tests/project-dialogs.test.tsx
new file mode 100644
index 0000000..e0f88d4
--- /dev/null
+++ b/packages/ui-patterns/tests/project-dialogs.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { SharedUIProvider } from "@microbit/ui";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ReactNode } from "react";
+import { IntlProvider } from "react-intl";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { NameProjectDialog } from "../src";
+
+afterEach(cleanup);
+
+const Providers = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+const dialog = () => within(screen.getByRole("dialog"));
+
+describe("NameProjectDialog", () => {
+ it("starts with the initial name and saves the trimmed name", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render(
+ {}}
+ onSave={onSave}
+ confirmText="Save"
+ />,
+ { wrapper: Providers },
+ );
+ expect(
+ dialog().getByRole("heading", { name: "Name your project" }),
+ ).toBeDefined();
+ const field = dialog().getByRole("textbox", { name: /Name/ });
+ expect((field as HTMLInputElement).value).toEqual("Old name");
+ await user.clear(field);
+ await user.type(field, " New name ");
+ await user.click(dialog().getByRole("button", { name: "Save" }));
+ expect(onSave).toHaveBeenCalledWith("New name");
+ });
+
+ it("submits on Enter", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render(
+ {}}
+ onSave={onSave}
+ confirmText="Save"
+ />,
+ { wrapper: Providers },
+ );
+ await user.type(dialog().getByRole("textbox", { name: /Name/ }), "{Enter}");
+ expect(onSave).toHaveBeenCalledWith("Project");
+ });
+
+ it("refuses a blank name", async () => {
+ const user = userEvent.setup();
+ const onSave = vi.fn();
+ render(
+ {}}
+ onSave={onSave}
+ confirmText="Save"
+ />,
+ { wrapper: Providers },
+ );
+ const field = dialog().getByRole("textbox", { name: /Name/ });
+ await user.clear(field);
+ await user.type(field, " ");
+ expect(
+ dialog().getByRole("button", { name: "Save" })
+ .disabled,
+ ).toBe(true);
+ await user.type(field, "{Enter}");
+ expect(onSave).not.toHaveBeenCalled();
+ expect(
+ dialog().getByText("The project name cannot be empty"),
+ ).toBeDefined();
+ });
+
+ it("takes a heading, confirm text and helper text", () => {
+ render(
+ {}}
+ onSave={() => {}}
+ heading="Rename project"
+ confirmText="Rename"
+ helperText="Used when you save."
+ />,
+ { wrapper: Providers },
+ );
+ expect(
+ dialog().getByRole("heading", { name: "Rename project" }),
+ ).toBeDefined();
+ expect(dialog().getByRole("button", { name: "Rename" })).toBeDefined();
+ expect(dialog().getByText("Used when you save.")).toBeDefined();
+ });
+});
diff --git a/packages/ui-patterns/tests/project-list.test.ts b/packages/ui-patterns/tests/project-list.test.ts
new file mode 100644
index 0000000..3db7396
--- /dev/null
+++ b/packages/ui-patterns/tests/project-list.test.ts
@@ -0,0 +1,136 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import {
+ defaultSortDirection,
+ rankProjects,
+ sortProjects,
+ useProjectSelection,
+} from "../src";
+
+interface P {
+ id: string;
+ name: string;
+ timestamp: number;
+ files: string[];
+}
+const p = (
+ id: string,
+ name: string,
+ timestamp: number,
+ files: string[] = [],
+) => ({
+ id,
+ name,
+ timestamp,
+ files,
+});
+const files = (project: P) => project.files;
+
+describe("rankProjects", () => {
+ const projects = [
+ p("a", "Heart", 1, ["main.py"]),
+ p("b", "Heartbeat monitor", 2, ["main.py", "sensor.py"]),
+ p("c", "Sensor log", 3, ["heart.py"]),
+ p("d", "Radio", 4),
+ ];
+
+ it("returns the projects unchanged for an empty query", () => {
+ expect(rankProjects(projects, " ")).toBe(projects);
+ });
+
+ it("requires every term to match the name or a secondary term", () => {
+ expect(
+ rankProjects(projects, "heart sensor", files).map((x) => x.id),
+ ).toEqual(["b", "c"]);
+ });
+
+ it("ranks exact, prefix then substring name matches above secondary matches", () => {
+ expect(rankProjects(projects, "heart", files).map((x) => x.id)).toEqual([
+ "a",
+ "b",
+ "c",
+ ]);
+ });
+
+ it("drops projects with no match and ignores case", () => {
+ expect(rankProjects(projects, "RADIO", files).map((x) => x.id)).toEqual([
+ "d",
+ ]);
+ });
+});
+
+describe("sortProjects", () => {
+ const projects = [
+ p("a", "banana", 2),
+ p("b", "Apple", 3),
+ p("c", "cherry", 1),
+ ];
+
+ it("sorts by name ignoring case", () => {
+ expect(sortProjects(projects, "name", "asc").map((x) => x.id)).toEqual([
+ "b",
+ "a",
+ "c",
+ ]);
+ expect(sortProjects(projects, "name", "desc").map((x) => x.id)).toEqual([
+ "c",
+ "a",
+ "b",
+ ]);
+ });
+
+ it("sorts by timestamp", () => {
+ expect(
+ sortProjects(projects, "timestamp", "desc").map((x) => x.id),
+ ).toEqual(["b", "a", "c"]);
+ });
+
+ it("does not mutate the input", () => {
+ const copy = [...projects];
+ sortProjects(projects, "name", "asc");
+ expect(projects).toEqual(copy);
+ });
+
+ it("defaults to newest first and A to Z", () => {
+ expect(defaultSortDirection("timestamp")).toEqual("desc");
+ expect(defaultSortDirection("name")).toEqual("asc");
+ });
+});
+
+describe("useProjectSelection", () => {
+ it("toggles, clears and remembers the last non-empty selection", () => {
+ const projects = [p("a", "A", 1), p("b", "B", 2)];
+ const { result } = renderHook(() => useProjectSelection(projects));
+ expect(result.current.hasSelection).toBe(false);
+
+ act(() => result.current.toggle("a"));
+ act(() => result.current.toggle("b"));
+ expect(result.current.selectedIds).toEqual(["a", "b"]);
+ expect(result.current.isSelected("a")).toBe(true);
+
+ act(() => result.current.toggle("a"));
+ expect(result.current.selectedIds).toEqual(["b"]);
+
+ act(() => result.current.clear());
+ expect(result.current.selectedIds).toEqual([]);
+ expect(result.current.hasSelection).toBe(false);
+ expect(result.current.lastSelectedIds).toEqual(["b"]);
+ });
+
+ it("drops a selected project that leaves the list", () => {
+ let projects = [p("a", "A", 1), p("b", "B", 2)];
+ const { result, rerender } = renderHook(() =>
+ useProjectSelection(projects),
+ );
+ act(() => result.current.toggle("a"));
+ act(() => result.current.toggle("b"));
+ projects = [p("b", "B", 2)];
+ rerender();
+ expect(result.current.selectedIds).toEqual(["b"]);
+ });
+});
diff --git a/packages/ui-patterns/tests/time-ago.test.ts b/packages/ui-patterns/tests/time-ago.test.ts
new file mode 100644
index 0000000..909ba32
--- /dev/null
+++ b/packages/ui-patterns/tests/time-ago.test.ts
@@ -0,0 +1,34 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { createIntl } from "react-intl";
+import { describe, expect, it } from "vitest";
+import { formatTimeAgo } from "../src";
+
+const intl = createIntl({ locale: "en" });
+const now = 1_700_000_000_000;
+
+describe("formatTimeAgo", () => {
+ it("says now within the same second", () => {
+ expect(formatTimeAgo(intl, now, now)).toEqual("now");
+ });
+
+ it("does not count seconds", () => {
+ expect(formatTimeAgo(intl, now - 20_000, now)).toEqual("a few seconds ago");
+ });
+
+ it("uses the largest whole unit", () => {
+ expect(formatTimeAgo(intl, now - 3 * 60_000, now)).toEqual("3 minutes ago");
+ expect(formatTimeAgo(intl, now - 2 * 3_600_000, now)).toEqual(
+ "2 hours ago",
+ );
+ expect(formatTimeAgo(intl, now - 5 * 86_400_000, now)).toEqual(
+ "5 days ago",
+ );
+ expect(formatTimeAgo(intl, now - 2 * 2_592_000_000, now)).toEqual(
+ "2 months ago",
+ );
+ });
+});
diff --git a/packages/ui/lang/ui.en.json b/packages/ui/lang/ui.en.json
index aca855f..6ed9561 100644
--- a/packages/ui/lang/ui.en.json
+++ b/packages/ui/lang/ui.en.json
@@ -3,6 +3,10 @@
"defaultMessage": "Breadcrumb",
"description": "Accessible label for the breadcrumb navigation trail (the WAI-ARIA APG's conventional name)"
},
+ "ui.cancel-action": {
+ "defaultMessage": "Cancel",
+ "description": "Cancel button in dialogs"
+ },
"ui.close-action": {
"defaultMessage": "Close",
"description": "Close button text or label"
diff --git a/packages/ui/src/ConfirmDialog.tsx b/packages/ui/src/ConfirmDialog.tsx
new file mode 100644
index 0000000..e2579ce
--- /dev/null
+++ b/packages/ui/src/ConfirmDialog.tsx
@@ -0,0 +1,66 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ReactNode, RefObject } from "react";
+import { FormattedMessage } from "react-intl";
+import { Button } from "./Button";
+import { uiMessage } from "./messages";
+import { Modal, ModalBody, ModalFooter, ModalHeader } from "./Modal";
+
+export interface ConfirmDialogProps {
+ isOpen: boolean;
+ heading: ReactNode;
+ body: ReactNode;
+ onConfirm: () => void;
+ onCancel: () => void;
+ /**
+ * The verb for what confirming does ("Delete", "Reset"). Required: there
+ * is always a better label than "Confirm".
+ */
+ confirmText: ReactNode;
+ /** Defaults to "Cancel". */
+ cancelText?: ReactNode;
+ finalFocusRef?: RefObject;
+ onCloseComplete?: () => void;
+}
+
+/**
+ * An alert dialog for a destructive action. Cancel takes initial focus, as
+ * the least destructive choice, and confirm is styled as dangerous.
+ */
+export const ConfirmDialog = ({
+ isOpen,
+ heading,
+ body,
+ onConfirm,
+ onCancel,
+ confirmText,
+ cancelText,
+ finalFocusRef,
+ onCloseComplete,
+}: ConfirmDialogProps) => (
+
+
+ {heading}
+
+ {body}
+
+
+
+
+
+);
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index ca005a3..f5444a6 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -57,6 +57,7 @@ export * from "./ListBox";
export * from "./ComboBox";
export * from "./Menu";
export * from "./Modal";
+export * from "./ConfirmDialog";
export * from "./PopoverArrow";
export * from "./SharedUIProvider";
export * from "./Tooltip";
diff --git a/packages/ui/stories/ConfirmDialog.stories.tsx b/packages/ui/stories/ConfirmDialog.stories.tsx
new file mode 100644
index 0000000..a5b1ddf
--- /dev/null
+++ b/packages/ui/stories/ConfirmDialog.stories.tsx
@@ -0,0 +1,52 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Box, Button, ConfirmDialog, Text, useDisclosure } from "../src";
+
+const meta = {
+ title: "Overlays/ConfirmDialog",
+ component: ConfirmDialog,
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+const Harness = ({ confirmText }: { confirmText: string }) => {
+ const disclosure = useDisclosure();
+ return (
+
+
+ Are you sure you want to delete the project "Heart"?}
+ confirmText={confirmText}
+ onConfirm={() => {
+ alert("deleted");
+ disclosure.onClose();
+ }}
+ onCancel={disclosure.onClose}
+ />
+
+ );
+};
+
+const args = {
+ isOpen: true,
+ heading: "Confirm delete project",
+ body: "",
+ confirmText: "Delete",
+ onConfirm: () => {},
+ onCancel: () => {},
+};
+
+/** Cancel takes focus first; the confirm verb is styled as dangerous. */
+export const Default: Story = {
+ args,
+ render: () => ,
+};
diff --git a/packages/ui/tests/ConfirmDialog.test.tsx b/packages/ui/tests/ConfirmDialog.test.tsx
new file mode 100644
index 0000000..a9d5690
--- /dev/null
+++ b/packages/ui/tests/ConfirmDialog.test.tsx
@@ -0,0 +1,44 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ReactNode } from "react";
+import { IntlProvider } from "react-intl";
+import { afterEach, expect, it, vi } from "vitest";
+import { ConfirmDialog, SharedUIProvider } from "../src";
+
+afterEach(cleanup);
+
+const Providers = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+it("is an alert dialog with cancel focused and a default cancel label", async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ const onCancel = vi.fn();
+ render(
+ ,
+ { wrapper: Providers },
+ );
+ const alert = within(screen.getByRole("alertdialog"));
+ expect(alert.getByRole("heading", { name: "Delete it?" })).toBeDefined();
+ const cancel = alert.getByRole("button", { name: "Cancel" });
+ expect(document.activeElement).toBe(cancel);
+ await user.click(alert.getByRole("button", { name: "Delete" }));
+ expect(onConfirm).toHaveBeenCalledOnce();
+ await user.click(cancel);
+ expect(onCancel).toHaveBeenCalledOnce();
+});