diff --git a/src/components/catalogue-toolbar.tsx b/src/components/catalogue-toolbar.tsx
new file mode 100644
index 0000000000..da8d92624d
--- /dev/null
+++ b/src/components/catalogue-toolbar.tsx
@@ -0,0 +1,7 @@
+export {
+ CatalogueToolbar,
+ type CatalogueToolbarProps,
+ type CatalogueToolbarSearchProps,
+ type CatalogueToolbarSortProps,
+ type CatalogueToolbarFilterTriggerProps,
+} from "@/components/ui/catalogue-toolbar";
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index 9785a2c38d..e79b2df1d8 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -24,9 +24,9 @@ import {
SessionPrivacyNote,
formulationCard,
} from "@/components/formulation/formulation-ui";
-import { Select } from "@/components/ui/select";
-import { TextField } from "@/components/ui/text-field";
+import { CatalogueToolbar } from "@/components/ui/catalogue-toolbar";
import { cn, eyebrowText } from "@/components/ui-primitives";
+
import {
findFormulationMechanism,
formulationDomains,
@@ -351,31 +351,31 @@ export function FormulationBuilderPage({
)}
-
- {/* Kept as a text input, not a `SearchField`: this filters the
- mechanism list in place and never submits, so it is not a
- second page composer (docs/search-chrome-behaviour.md). */}
- setQuery(event.target.value)}
- placeholder="Search mechanisms or patient language..."
- className="font-semibold"
- />
- setDomain(event.target.value)}
- className="font-semibold"
- options={[
+ ({ value: item, label: item })),
- ]}
- />
-
+ ],
+ }}
+ appliedFilters={
+ domain !== "all"
+ ? [{ id: "domain", groupLabel: "Domain", valueLabel: domain, onRemove: () => setDomain("all") }]
+ : []
+ }
+ onClearFilters={domain !== "all" ? () => setDomain("all") : undefined}
+ matchCount={visibleMechanisms.length}
+ noun="mechanism"
+ />
{visibleMechanisms.map((mechanism) => {
diff --git a/src/components/ui/catalogue-toolbar.tsx b/src/components/ui/catalogue-toolbar.tsx
new file mode 100644
index 0000000000..19234da910
--- /dev/null
+++ b/src/components/ui/catalogue-toolbar.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import type { ChangeEvent, ReactNode } from "react";
+import { Filter, Search, X } from "lucide-react";
+
+import { cn } from "@/components/ui-primitives";
+import { TextField } from "@/components/ui/text-field";
+import { Select } from "@/components/ui/select";
+import type { AppliedFilterChip } from "@/components/clinical-dashboard/search-results-header-band";
+
+export type CatalogueToolbarSearchProps = {
+ query: string;
+ onQueryChange: (query: string) => void;
+ placeholder?: string;
+ label?: string;
+ hideLabel?: boolean;
+ disabled?: boolean;
+ className?: string;
+};
+
+export type CatalogueToolbarSortProps = {
+ value: string;
+ onChange: (value: string) => void;
+ options: ReadonlyArray<{ value: string; label: string }>;
+ label?: string;
+ hideLabel?: boolean;
+ disabled?: boolean;
+ className?: string;
+};
+
+export type CatalogueToolbarFilterTriggerProps = {
+ open?: boolean;
+ onToggle?: () => void;
+ activeCount?: number;
+ label?: string;
+ panelId?: string;
+ testId?: string;
+ disabled?: boolean;
+};
+
+export type CatalogueToolbarProps = {
+ /** Search control configuration or custom node. */
+ search?: CatalogueToolbarSearchProps | ReactNode;
+ /** Sort select configuration or custom node. */
+ sort?: CatalogueToolbarSortProps | ReactNode;
+ /** Filter trigger configuration or custom trigger. */
+ filterTrigger?: CatalogueToolbarFilterTriggerProps | ReactNode;
+ /** Active filter chips shown below or alongside the toolbar. */
+ appliedFilters?: readonly AppliedFilterChip[];
+ /** Callback when the user clears all applied filters. */
+ onClearFilters?: () => void;
+ /** Match count / results readout. */
+ matchCount?: number;
+ /** Singular noun for match count (e.g. "mechanism", "specifier", "differential"). */
+ noun?: string;
+ /** Plural form of `noun`, for words that don't pluralize with a trailing "s" (e.g. "status" -> "statuses"). Defaults to `${noun}s`. */
+ pluralNoun?: string;
+ /** Status of the catalogue search. */
+ status?: "ready" | "loading" | "refetching" | "error" | "unauthorized";
+ /** Additional action items (e.g. Compare, View toggle, Create/Build). */
+ actions?: ReactNode;
+ /** Children rendered in the main controls row or below. */
+ children?: ReactNode;
+ className?: string;
+ testId?: string;
+};
+
+function isSearchProps(search: CatalogueToolbarSearchProps | ReactNode): search is CatalogueToolbarSearchProps {
+ return typeof search === "object" && search !== null && "query" in search && "onQueryChange" in search;
+}
+
+function isSortProps(sort: CatalogueToolbarSortProps | ReactNode): sort is CatalogueToolbarSortProps {
+ return typeof sort === "object" && sort !== null && "value" in sort && "onChange" in sort && "options" in sort;
+}
+
+function isFilterTriggerProps(
+ filter: CatalogueToolbarFilterTriggerProps | ReactNode,
+): filter is CatalogueToolbarFilterTriggerProps {
+ return (
+ typeof filter === "object" &&
+ filter !== null &&
+ ["open", "onToggle", "activeCount", "label", "panelId", "testId", "disabled"].some((key) => key in filter)
+ );
+}
+
+/**
+ * Standardized Catalogue Toolbar (`
`) component.
+ *
+ * Consolidates search, sort, filter triggers, applied filter chips, and action
+ * controls into a single coherent, accessible responsive bar for catalogue surfaces
+ * (Differentials, Formulations, Specifiers, Services, Factsheets).
+ */
+export function CatalogueToolbar({
+ search,
+ sort,
+ filterTrigger,
+ appliedFilters = [],
+ onClearFilters,
+ matchCount,
+ noun = "item",
+ pluralNoun,
+ status = "ready",
+ actions,
+ children,
+ className,
+ testId = "catalogue-toolbar",
+}: CatalogueToolbarProps) {
+ const hasAppliedFilters = appliedFilters.length > 0;
+ const resolvedPluralNoun = pluralNoun ?? `${noun}s`;
+ const countLabel =
+ typeof matchCount === "number" ? `${matchCount} ${matchCount === 1 ? noun : resolvedPluralNoun}` : null;
+
+ return (
+
+ {/* Primary Toolbar Row */}
+
+
+ {/* Search control */}
+ {search ? (
+
+ {isSearchProps(search) ? (
+ ) => search.onQueryChange(e.target.value)}
+ placeholder={search.placeholder ?? `Search ${resolvedPluralNoun}...`}
+ disabled={search.disabled}
+ className={cn("font-semibold", search.className)}
+ />
+ ) : (
+ search
+ )}
+
+ ) : null}
+
+ {/* Sort control */}
+ {sort ? (
+
+ {isSortProps(sort) ? (
+ ) => sort.onChange(e.target.value)}
+ options={[...sort.options]}
+ disabled={sort.disabled}
+ className={cn("font-semibold", sort.className)}
+ />
+ ) : (
+ sort
+ )}
+
+ ) : null}
+
+ {/* Filter trigger button */}
+ {filterTrigger ? (
+
+ {isFilterTriggerProps(filterTrigger) ? (
+ 0
+ ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-heading)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ )}
+ >
+
+ {filterTrigger.label ?? "Filter"}
+ {(filterTrigger.activeCount ?? 0) > 0 ? (
+
+ {filterTrigger.activeCount}
+
+ ) : null}
+
+ ) : (
+ filterTrigger
+ )}
+
+ ) : null}
+
+ {/* Optional inline custom children */}
+ {children}
+
+
+ {/* Right side: Count readout & Actions */}
+
+ {countLabel ? (
+
+ {countLabel}
+
+ ) : null}
+
+ {actions ?
{actions}
: null}
+
+
+
+ {/* Applied Filter Chips Strip */}
+ {hasAppliedFilters ? (
+
+ Active filters:
+ {appliedFilters.map((chip) => (
+
+
+ {chip.groupLabel ? {chip.groupLabel}: : null}
+ {chip.valueLabel}
+
+
+
+
+
+ ))}
+
+ {onClearFilters ? (
+
+ Clear all
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/tests/catalogue-toolbar.dom.test.tsx b/tests/catalogue-toolbar.dom.test.tsx
new file mode 100644
index 0000000000..6e4f871072
--- /dev/null
+++ b/tests/catalogue-toolbar.dom.test.tsx
@@ -0,0 +1,149 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { CatalogueToolbar } from "@/components/ui/catalogue-toolbar";
+
+describe("CatalogueToolbar DOM and Interactions", () => {
+ it("renders search, sort, filter trigger, and match count cleanly", () => {
+ const onSearchChange = vi.fn();
+ const onSortChange = vi.fn();
+ const onToggleFilter = vi.fn();
+
+ render(
+
,
+ );
+
+ expect(screen.getByTestId("catalogue-toolbar")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Search differentials...")).toHaveValue("neuro");
+ expect(screen.getByRole("combobox")).toHaveValue("relevance");
+
+ const trigger = screen.getByTestId("catalogue-filter-trigger");
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(trigger).toHaveAttribute("aria-controls", "diff-filter-panel");
+ expect(screen.getByTestId("catalogue-filter-badge")).toHaveTextContent("2");
+
+ const matchCount = screen.getByTestId("catalogue-match-count");
+ expect(matchCount).toHaveTextContent("14 differentials");
+ });
+
+ it("handles user interactions for search, sort, and filter trigger", async () => {
+ const onSearchChange = vi.fn();
+ const onSortChange = vi.fn();
+ const onToggleFilter = vi.fn();
+
+ render(
+
,
+ );
+
+ const input = screen.getByRole("textbox");
+ await userEvent.type(input, "bipolar");
+ expect(onSearchChange).toHaveBeenCalled();
+
+ const select = screen.getByRole("combobox");
+ await userEvent.selectOptions(select, "alpha");
+ expect(onSortChange).toHaveBeenCalledWith("alpha");
+
+ const trigger = screen.getByTestId("catalogue-filter-trigger");
+ await userEvent.click(trigger);
+ expect(onToggleFilter).toHaveBeenCalledTimes(1);
+ });
+
+ it("defaults the filter trigger to collapsed and disabled when no toggle handler is provided", () => {
+ render(
+
,
+ );
+
+ const trigger = screen.getByTestId("catalogue-filter-trigger");
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(trigger).toBeDisabled();
+ });
+
+ it("renders active filter chips and handles remove and clear-all callbacks", async () => {
+ const onRemoveDomain = vi.fn();
+ const onRemoveScope = vi.fn();
+ const onClearAll = vi.fn();
+
+ render(
+
,
+ );
+
+ const chipsStrip = screen.getByTestId("catalogue-applied-filters");
+ expect(chipsStrip).toBeInTheDocument();
+
+ const chips = within(chipsStrip).getAllByTestId("catalogue-applied-chip");
+ expect(chips).toHaveLength(2);
+ expect(chips[0]).toHaveTextContent("Domain: Biological");
+ expect(chips[1]).toHaveTextContent("Scope: Guides");
+
+ const removeDomainBtn = screen.getByRole("button", { name: "Remove filter Domain: Biological" });
+ await userEvent.click(removeDomainBtn);
+ expect(onRemoveDomain).toHaveBeenCalledTimes(1);
+
+ const clearAllBtn = screen.getByTestId("catalogue-clear-filters");
+ await userEvent.click(clearAllBtn);
+ expect(onClearAll).toHaveBeenCalledTimes(1);
+ });
+
+ it("handles singular vs plural noun formatting in results count", () => {
+ const { rerender } = render(
);
+ expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("1 mechanism");
+
+ rerender(
);
+ expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("0 mechanisms");
+
+ rerender(
);
+ expect(screen.getByTestId("catalogue-match-count")).toHaveTextContent("5 specifiers");
+ });
+});