Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
53a7f17
feat(filters): converge factsheets category filter onto SegmentedCont…
claude Aug 12, 2026
c67e3e4
chore(ledger): record PR D review (factsheets filter convergence)
claude Aug 12, 2026
eccc7e3
fix(factsheets): prevent selectable zero-count categories
BigSimmo Aug 12, 2026
4bc75d2
style(factsheets): restore terminal newlines
BigSimmo Aug 12, 2026
eaee186
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
9e4409b
docs(ledger): retain latest-main issues ledger
BigSimmo Aug 12, 2026
6c30e2b
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
94a6be0
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
88807d7
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
6726887
chore(factsheets): apply required Prettier formatting
BigSimmo Aug 12, 2026
dc61f6e
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
fa36920
chore(factsheets): restore formatter EOF newlines
BigSimmo Aug 12, 2026
22d3186
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
540a13c
chore(factsheets): satisfy changed-file formatter
BigSimmo Aug 12, 2026
5580b54
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 12, 2026
68d5e06
Fix factsheets PR formatting and adoption manifest
BigSimmo Aug 12, 2026
efbf324
Merge remote-tracking branch 'refs/remotes/origin/claude/filter-facts…
BigSimmo Aug 12, 2026
01bd309
Merge branch 'main' into claude/filter-factsheets-composer
BigSimmo Aug 13, 2026
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
2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 |
| `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 |
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 7 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
Expand Down
3 changes: 3 additions & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,7 @@
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/specifiers/specifiers-home-page.tsx"
],
Expand All @@ -1406,6 +1407,7 @@
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/factsheets/factsheets-search-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/specifiers/specifiers-home-page.tsx"
],
Expand All @@ -1417,6 +1419,7 @@
},
"testFiles": [
"tests/design-sync-visual-exports.test.ts",
"tests/factsheets-search-page.dom.test.tsx",
"tests/search-results-header-band.dom.test.tsx",
"tests/ui-v2-components.dom.test.tsx"
],
Expand Down
26 changes: 20 additions & 6 deletions docs/filter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ does today — tells the reader they cannot hold two domains at once, which is f

### There is no `navigate` kind, and that is the point

Services' quick filters and factsheets' categories do not filter. They call `router.push` and
**replace the query**, so choosing one discards the search and its results with no warning and no
undo. A control labelled "Filter" must not do that. Query-replacing presets belong beside the
composer as suggested searches (`AnswerSuggestionChips`), not inside the filter sheet.
Services' quick filters do not filter. They call `router.push` and **replace the query**, so
choosing one discards the search and its results with no warning and no undo. A control labelled
"Filter" must not do that. Query-replacing presets belong beside the composer as suggested
searches (`AnswerSuggestionChips`), not inside the filter sheet.

Factsheets' category dimension is not this pattern, despite an earlier draft of this section
grouping it with services' quick filters: `filterFactsheets(query, category)` ANDs the two, so
selecting a category narrows within the current search and preserves `q` — it is a real `lens`
(one-of-N, exactly the shape this section describes above), not a query-replacing preset. Its
actual defect was the one section 2 names next: the desktop rail used raw `<Link>` chips while
the phone sheet correctly used `resultFilterGroup`, so the two breakpoints disagreed on
component even though they agreed on values. Converged, not evicted — see the Rollout section.

Until a mode has real facets, it is better for its filter trigger to be absent than to open a
sheet that throws the query away.
Expand Down Expand Up @@ -139,8 +147,14 @@ Contract first, then one PR per mode:

1. **Contract** — add the kinds, the facet renderer and the builders, changing no rendered output.
2. **Per mode** — adopt the right kind, derive the option list, add counts, and retire that mode's
desktop rail so the breakpoints stop disagreeing.
3. **Services and factsheets** — evict the query-replacing presets to the composer.
desktop rail so the breakpoints stop disagreeing. Done for differentials, medication,
applications, specifiers (all `lens`, PR A) and formulation (`facet`, PR B). Also done for
factsheets: its category dimension was already a real `lens` (see the corrected note above),
so this PR converges its desktop rail onto `SegmentedControl` sharing one counted option array
with the phone sheet, rather than evicting anything — there was no query-replacing preset to
evict.
3. **Services** — evict the six query-replacing quick filters to the composer, alongside its own
facet/scope work (open as a separate PR).
4. **Documents last** — port its needle and collapse up into the shared component as the
`> 20` tier, then converge. It is the largest surface and should move once the contract is
proven elsewhere.
116 changes: 73 additions & 43 deletions src/components/factsheets/factsheets-search-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronRight, Info, LayoutGrid, List, SearchX } from "lucide-react";
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";

import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import {
ResultFilterSheet,
ResultFilterTrigger,
resultFilterGroup,
type ResultFilterOption,
} from "@/components/clinical-dashboard/result-filter-control";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
categoryTheme,
factsheetCategories,
filterFactsheets,
type Factsheet,
type FactsheetCategory,
} from "@/components/factsheets/factsheets-data";
import { factsheetGlyph } from "@/components/factsheets/factsheets-icons";
import { cn } from "@/components/ui-primitives";
Expand All @@ -30,11 +32,6 @@ function searchHref(query: string, category?: string) {
return suffix ? `/factsheets/search?${suffix}` : "/factsheets/search";
}

const filterChips: Array<{ key?: FactsheetCategory; label: string }> = [
{ key: undefined, label: "All" },
...factsheetCategories.map((category) => ({ key: category, label: category })),
];

export function FactsheetsSearchPage({
query,
category,
Expand All @@ -48,8 +45,52 @@ export function FactsheetsSearchPage({
const [view, setView] = useState<ViewMode>("list");
const activeCategory = factsheetCategories.find((entry) => entry === category);
const filterPanelId = useId();
const categoryDeadEndNoteId = useId();
const [filterOpen, setFilterOpen] = useState(false);

// Category genuinely narrows within the current search — `filterFactsheets`
// ANDs query and category — unlike services' quick filters or formulation's
// old pattern presets, which replaced the query outright. So this is a real
// lens (docs/filter-contract.md section 1), not something to evict.
//
// Query-matched, category-UNfiltered, so a hint answers "how many for this
// category, at the current query". Derive the option list from those matches:
// unrelated zero-member categories do not exist. A selected category that the
// query has narrowed to zero stays visible as a disabled, explained dead end
// so URL state remains truthful and the available options provide an escape.
const queryMatches = useMemo(() => filterFactsheets(query), [query]);
const categoryOptions = useMemo<ReadonlyArray<ResultFilterOption<string>>>(() => {
const categoryCounts = new Map<string, number>();
for (const sheet of queryMatches) {
categoryCounts.set(sheet.category, (categoryCounts.get(sheet.category) ?? 0) + 1);
}

const options: ResultFilterOption<string>[] = [{ value: "all", label: "All", hint: String(queryMatches.length) }];
for (const entry of factsheetCategories) {
const count = categoryCounts.get(entry) ?? 0;
if (count === 0 && entry !== activeCategory) continue;
options.push({
value: entry,
label: entry,
hint: String(count),
disabled: count === 0,
});
}
return options;
}, [activeCategory, queryMatches]);
const activeCategoryIsDeadEnd = Boolean(
activeCategory && categoryOptions.find((option) => option.value === activeCategory)?.disabled,
);
const activeCategoryDeadEndMessage = activeCategoryIsDeadEnd
? `No results in ${activeCategory} for this search. Choose All or another available category.`
: undefined;
const applyCategory = (value: string) => {
const option = categoryOptions.find((entry) => entry.value === value);
if (!option || option.disabled) return;
setFilterOpen(false);
router.push(searchHref(query, value === "all" ? undefined : value));
};

return (
<div
data-testid="factsheets-search-page"
Expand Down Expand Up @@ -78,29 +119,28 @@ export function FactsheetsSearchPage({
onToggle={() => setFilterOpen((current) => !current)}
/>
}
// The same control the sheet renders below, sharing `categoryOptions` —
// the two breakpoints used to disagree on renderer (raw `<Link>` chips
// here, a real radiogroup in the sheet) even though the VALUES already
// agreed. One shared array is what stops that drifting again.
filterControls={
<div className="polished-scroll flex min-w-0 items-center gap-1.5 overflow-x-auto">
<span className="hidden shrink-0 text-3xs font-extrabold uppercase tracking-kicker text-[color:var(--text-muted)] sm:inline">
Category
</span>
{filterChips.map((chip) => {
const isActive = chip.key ? activeCategory === chip.key : !activeCategory;
return (
<Link
key={chip.label}
href={searchHref(query, chip.key)}
aria-current={isActive ? "true" : undefined}
className={cn(
"inline-flex min-h-tap shrink-0 items-center rounded-lg border px-2.5 text-2xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9 sm:text-xs",
isActive
? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]",
)}
>
{chip.label}
</Link>
);
})}
<div className="grid min-w-0 gap-1.5">
<SegmentedControl
label="Category"
value={activeCategory ?? "all"}
onChange={applyCategory}
options={categoryOptions}
ariaDescribedBy={activeCategoryDeadEndMessage ? categoryDeadEndNoteId : undefined}
/>
{activeCategoryDeadEndMessage ? (
<p
id={categoryDeadEndNoteId}
data-testid="factsheet-category-dead-end"
className="text-xs font-medium leading-5 text-[color:var(--text-muted)]"
>
{activeCategoryDeadEndMessage}
</p>
) : null}
</div>
}
/>
Expand Down Expand Up @@ -155,22 +195,12 @@ export function FactsheetsSearchPage({
id: "category",
label: "Category",
value: activeCategory ?? "all",
options: filterChips.map((chip) => ({ value: chip.key ?? "all", label: chip.label })),
onChange: (value) => {
setFilterOpen(false);
router.push(searchHref(query, value === "all" ? undefined : value));
},
options: categoryOptions,
onChange: applyCategory,
}),
]}
onClearAll={
activeCategory
? () => {
setFilterOpen(false);
router.push(searchHref(query));
}
: undefined
}
footerNote={`${results.length} showing`}
onClearAll={activeCategory ? () => applyCategory("all") : undefined}
footerNote={activeCategoryDeadEndMessage ?? `${results.length} showing`}
/>

{results.length === 0 ? (
Expand Down
120 changes: 120 additions & 0 deletions tests/factsheets-search-page.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/** @vitest-environment jsdom */

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
}));

import { FactsheetsSearchPage } from "@/components/factsheets/factsheets-search-page";
import { filterFactsheets } from "@/components/factsheets/factsheets-data";

// "sertraline" matches exactly one factsheet (Medications) — a small, stable
// fixture that still exercises real counting rather than an empty result set.
const query = "sertraline";
const results = filterFactsheets(query);
const unrelatedZeroLabels = ["Conditions (0)", "Therapies (0)", "Tests & procedures (0)"] as const;
const selectedZeroMessage = "No results in Conditions for this search. Choose All or another available category.";

describe("FactsheetsSearchPage category filter", () => {
it("derives the shared desktop and phone options from categories available at the current query", async () => {
const user = userEvent.setup();
render(<FactsheetsSearchPage query={query} results={results} />);

// Desktop: a real SegmentedControl (radiogroup), not `<Link>` chips —
// the drift this PR closes (docs/filter-contract.md section 2).
const desktopGroup = screen.getByRole("radiogroup", { name: "Category" });
expect(within(desktopGroup).getByRole("radio", { name: "All (1)" })).toBeChecked();
expect(within(desktopGroup).getByRole("radio", { name: "Medications (1)" })).toBeInTheDocument();
for (const label of unrelatedZeroLabels) {
expect(within(desktopGroup).queryByRole("radio", { name: label })).not.toBeInTheDocument();
}

await user.click(screen.getByTestId("factsheet-filter-trigger-phone"));
const sheet = screen.getByTestId("factsheet-filter-panel");
const sheetGroup = within(sheet).getByRole("radiogroup", { name: "Category" });
expect(within(sheetGroup).getByRole("radio", { name: "All (1)" })).toBeChecked();
expect(within(sheetGroup).getByRole("radio", { name: "Medications (1)" })).toBeInTheDocument();
for (const label of unrelatedZeroLabels) {
expect(within(sheetGroup).queryByRole("radio", { name: label })).not.toBeInTheDocument();
}
});

it("selecting a category narrows within the query rather than replacing it", async () => {
const user = userEvent.setup();
push.mockClear();
render(<FactsheetsSearchPage query={query} results={results} />);

const desktopGroup = screen.getByRole("radiogroup", { name: "Category" });
await user.click(within(desktopGroup).getByRole("radio", { name: "Medications (1)" }));

expect(push).toHaveBeenCalledTimes(1);
const href = push.mock.calls[0][0] as string;
// Both params survive — category narrows, it does not replace the search.
expect(href).toContain(`q=${encodeURIComponent(query)}`);
expect(href).toContain("category=Medications");
});

it("clearing the category preserves the query", async () => {
const user = userEvent.setup();
push.mockClear();
render(<FactsheetsSearchPage query={query} category="Medications" results={results} />);

await user.click(screen.getByTestId("factsheet-filter-trigger-phone"));
await user.click(screen.getByTestId("factsheet-filter-panel-clear"));

expect(push).toHaveBeenCalledTimes(1);
const href = push.mock.calls[0][0] as string;
expect(href).toContain(`q=${encodeURIComponent(query)}`);
expect(href).not.toContain("category=");
});

it("keeps a selected zero-count category as an explained, inert dead end", async () => {
const user = userEvent.setup();
push.mockClear();
render(
<FactsheetsSearchPage query={query} category="Conditions" results={filterFactsheets(query, "Conditions")} />,
);

const desktopGroup = screen.getByRole("radiogroup", { name: "Category" });
expect(within(desktopGroup).getByRole("radio", { name: "All (1)" })).toBeEnabled();
expect(within(desktopGroup).getByRole("radio", { name: "Medications (1)" })).toBeEnabled();
const selectedZero = within(desktopGroup).getByRole("radio", { name: "Conditions (0)" });
expect(selectedZero).toBeChecked();
expect(selectedZero).toBeDisabled();
expect(screen.getByTestId("factsheet-category-dead-end")).toHaveTextContent(selectedZeroMessage);
expect(within(desktopGroup).queryByRole("radio", { name: "Therapies (0)" })).not.toBeInTheDocument();
expect(within(desktopGroup).queryByRole("radio", { name: "Tests & procedures (0)" })).not.toBeInTheDocument();

await user.click(selectedZero);
expect(push).not.toHaveBeenCalled();

await user.click(screen.getByTestId("factsheet-filter-trigger-phone"));
const sheet = screen.getByTestId("factsheet-filter-panel");
const sheetGroup = within(sheet).getByRole("radiogroup", { name: "Category" });
const sheetSelectedZero = within(sheetGroup).getByRole("radio", { name: "Conditions (0)" });
expect(sheetSelectedZero).toBeChecked();
expect(within(sheet).getByText(selectedZeroMessage)).toBeInTheDocument();

await user.click(sheetSelectedZero);
expect(push).not.toHaveBeenCalled();
await user.click(within(sheetGroup).getByRole("radio", { name: "Medications (1)" }));

expect(push).toHaveBeenCalledTimes(1);
const href = push.mock.calls[0][0] as string;
expect(href).toContain(`q=${encodeURIComponent(query)}`);
expect(href).toContain("category=Medications");
});

it("shows only the All escape when the current query has no category matches", () => {
render(<FactsheetsSearchPage query="zzz-no-such-factsheet" results={[]} />);
const desktopGroup = screen.getByRole("radiogroup", { name: "Category" });
expect(within(desktopGroup).getByRole("radio", { name: "All (0)" })).toBeEnabled();
for (const category of ["Medications", "Conditions", "Therapies", "Tests & procedures"]) {
expect(within(desktopGroup).queryByRole("radio", { name: `${category} (0)` })).not.toBeInTheDocument();
}
});
});
Loading