diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 52d89db67c..ec26f06e2b 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -116,17 +116,17 @@ corpus of that size, and it stays. ## 5. Density is a function of option count -Facet groups only. Two states, not three: the shared renderer uses the same threshold for its -find-a-filter field and collapse-by-default disclosures. +Facet groups only. Density scales with option and group volume across three tiers: -| Options | Renderer | -| --------------------------- | ---------------------------------------------------------------------------------------- | -| ≤ 3 groups and ≤ 20 options | chips, single row where they fit (unchanged from before this section) | -| > 3 groups, or > 20 options | chips plus find-a-filter and collapse-by-default, every group behind a disclosure header | +| Options / Groups | Renderer | +| --------------------------- | -------------------------------------------------------------------------------------- | +| ≤ 5 options | chips, single row / wrapping chips | +| 6–20 options | dense full-width vertical list with right-aligned count column and group headings | +| > 3 groups, or > 20 options | list/chips plus find-a-filter and collapse-by-default, every group behind a disclosure | -`ResultFilterSheet` computes the threshold once across all facet groups. The option-count limb -catches a small number of very large groups, while the group-count limb covers services' six -facet groups. Below the threshold every group renders as before. +`ResultFilterSheet` computes the threshold across facet groups. Facet groups containing 6–20 options +render as compact full-width rows with a right-aligned count column for fast scanning. When a sheet +exceeds 3 groups or 20 total options, it additionally adds find-a-filter and collapse-by-default chrome. Collapse rules, when they apply: groups start collapsed; a group holding a selection opens itself; an explicit user collapse beats that; an active needle forces every matched group open diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bafb30038e..459da9790b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1404,7 +1404,10 @@ export function DocumentViewer({ a phone reader sees the clinical priorities digest before scrolling past the PDF. */} {readyDocument ? ( -
+
)} - {/* No privacy link here: the composer's PrivacyInputNotice is the - single site-wide notice, so the hero footer must not repeat it. */} - {/* Pre-query copy must describe what the search does, not assert that - every indexed source is verified/current (PT-06): validation status - varies per document and is surfaced on the results themselves. */} + {modeId === "answer" ? ( + + ) : null}
} /> diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 5b1a72668a..0fa8256ba6 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -35,6 +35,14 @@ import { type FavouriteItem as PrototypeFavouriteItem, } from "@/components/clinical-dashboard/favourites-prototype-data"; import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; +import { + formatLastOpened, + lastOpenedScore, + loadFavouriteLastOpened, + loadFavouritePinnedIds, + recordFavouriteOpened, + subscribeFavouritesStorage, +} from "@/components/favourites/favourites-storage"; import { SearchResultsEmptyState, SearchResultsHeaderBand, @@ -112,8 +120,6 @@ const lastUsedByItemId: Record = { "qt-prolongation-quote": "Mon 11:03", }; -const pinnedItemIds = new Set(["acamprosate-renal-screen", "lithium-monitoring-guideline"]); - const typeByPrototypeType: Record = { medications: "Medication", documents: "Document", @@ -186,7 +192,11 @@ async function copyFavouriteCitation(item: FavouriteItem): Promise { } } -function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { +function toCommandItem( + item: PrototypeFavouriteItem, + lastOpenedMap: Record, + pinnedIds: ReadonlySet, +): FavouriteItem { const type = item.type === "sources" && item.primaryAction === "Run" ? "Saved search" @@ -199,11 +209,14 @@ function toCommandItem(item: PrototypeFavouriteItem): FavouriteItem { tabId: item.type, set: item.set || (item.type === "services" ? "Saved services" : item.type === "forms" ? "Saved forms" : "Unsorted"), evidence: item.sourceMeta, - lastUsed: lastUsedByItemId[item.id] ?? "Saved", + lastUsed: + lastOpenedMap[item.id] !== undefined + ? formatLastOpened(lastOpenedMap[item.id]) + : (lastUsedByItemId[item.id] ?? "Saved"), action: item.primaryAction, href: item.href, icon: item.icon ?? fallbackIconByType[item.type], - pinned: pinnedItemIds.has(item.id), + pinned: pinnedIds.has(item.id), }; } @@ -1106,9 +1119,18 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: status: favouritesHookStatus, refetch: refetchFavouritesRegistry, } = useSavedRegistryFavourites(); + const lastOpenedMap = useSyncExternalStore( + subscribeFavouritesStorage, + loadFavouriteLastOpened, + () => ({}) as Record, + ); + const pinnedIds = useSyncExternalStore(subscribeFavouritesStorage, loadFavouritePinnedIds, () => new Set()); const items = useMemo( - () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem), - [demoMode, savedRegistryFavourites], + () => + [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map((item) => + toCommandItem(item, lastOpenedMap, pinnedIds), + ), + [demoMode, savedRegistryFavourites, lastOpenedMap, pinnedIds], ); // Demo prototypes live outside the hook. If they are the only items while a // registry/account read failed, keep their honest nonzero count but mark it @@ -1162,7 +1184,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const recentItems = useMemo( () => [...items] - .sort((first, second) => lastUsedScore(second.lastUsed) - lastUsedScore(first.lastUsed)) + .sort((first, second) => lastOpenedScore(second.lastUsed) - lastOpenedScore(first.lastUsed)) .slice(0, recentPreviewLimit), [items], ); @@ -1517,7 +1539,10 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: sortMode={sortMode} selectedItemId={selectedItemId} onSortModeChange={setSortMode} - onSelectItem={setSelectedItemId} + onSelectItem={(id) => { + if (id) recordFavouriteOpened(id); + setSelectedItemId(id); + }} /> )} @@ -1548,6 +1573,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: recordFavouriteOpened(item.id)} aria-label={`Open ${item.title}`} className={cn( "inline-flex min-h-tap shrink-0 items-center rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-bold text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)] sm:min-h-9", diff --git a/src/components/clinical-dashboard/result-filter-control.tsx b/src/components/clinical-dashboard/result-filter-control.tsx index 71fbd46246..e9ede86aeb 100644 --- a/src/components/clinical-dashboard/result-filter-control.tsx +++ b/src/components/clinical-dashboard/result-filter-control.tsx @@ -479,7 +479,12 @@ export function ResultFilterFacetChips({ const panelId = idPrefix; const groupLabelId = `${panelId}-${group.id}-label`; const visibleOptions = options ?? group.options; - const renderOptions = (items: ReadonlyArray>) => + const isDenseList = + !group.optionSections && + ((group.options.length >= 6 && group.options.length <= 20) || + (visibleOptions.length >= 6 && visibleOptions.length <= 20)); + + const renderOptions = (items: ReadonlyArray>, isDense: boolean = isDenseList) => items.map((option) => { const selected = group.selected.has(option.value); const deadEnd = Boolean(option.disabled) && !selected; @@ -497,7 +502,9 @@ export function ResultFilterFacetChips({ group.onToggle(option.value); }} className={cn( - "inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:gap-1 sm:px-2", + isDense + ? "flex min-h-tap w-full min-w-0 items-center justify-between gap-2.5 rounded-lg border px-3 py-2 text-left text-xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-9 sm:py-1.5" + : "inline-flex min-h-tap max-w-full items-center gap-1.5 rounded-md border px-2.5 text-2xs font-semibold shadow-[var(--shadow-inset)] transition motion-reduce:transition-none sm:min-h-10 sm:gap-1 sm:px-2", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected ? "border-[color:var(--clinical-accent)]/35 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" @@ -506,19 +513,25 @@ export function ResultFilterFacetChips({ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", )} > - - {selected ? - {option.label} - {option.hint ? {option.hint} : null} +
+ + {selected ? + {option.label} +
+ {option.hint ? ( + + {option.hint} + + ) : null} {deadEnd ? ( No matches with your current filters. @@ -583,12 +596,18 @@ export function ResultFilterFacetChips({ hidden={disclosure ? !disclosure.open : false} role="group" aria-labelledby={groupLabelId} - className={cn("pb-2.5", group.optionSections ? "grid gap-3" : "flex flex-wrap gap-2 sm:gap-1.5")} + className={cn( + "pb-2.5", + group.optionSections ? "grid gap-3" : isDenseList ? "grid gap-1" : "flex flex-wrap gap-2 sm:gap-1.5", + )} > {group.optionSections ? group.optionSections.map((section) => { const sectionOptions = visibleOptions.filter((option) => section.optionValues.includes(option.value)); if (sectionOptions.length === 0) return null; + const sectionDense = + (section.optionValues.length >= 6 && section.optionValues.length <= 20) || + (sectionOptions.length >= 6 && sectionOptions.length <= 20); return (
@@ -599,11 +618,13 @@ export function ResultFilterFacetChips({

) : null}
-
{renderOptions(sectionOptions)}
+
+ {renderOptions(sectionOptions, sectionDense)} +
); }) - : renderOptions(visibleOptions)} + : renderOptions(visibleOptions, isDenseList)}
); diff --git a/src/components/document-viewer/document-rail-panels.tsx b/src/components/document-viewer/document-rail-panels.tsx index 3918810d69..58dec45dd1 100644 --- a/src/components/document-viewer/document-rail-panels.tsx +++ b/src/components/document-viewer/document-rail-panels.tsx @@ -138,7 +138,7 @@ export function DocumentViewerRail({ data-testid="high-yield-summary" className={cn( panel, - "group min-w-0 scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", + "group min-w-0 max-sm:hidden print:block scroll-mt-[var(--document-anchor-offset,6rem)] source-print md:col-span-2 lg:col-span-1", )} > = { "source-evidence": ["source-evidence-rail"], + // The rail's document-profile disclosure owns the canonical anchor (the spy + // treats it as an exclusive-accordion member), while the in-flow clinical + // summary card above the PDF is its phone/tablet copy. + "source-summary": ["source-summary-card"], }; /** diff --git a/src/components/favourites/favourites-storage.ts b/src/components/favourites/favourites-storage.ts new file mode 100644 index 0000000000..a658faba01 --- /dev/null +++ b/src/components/favourites/favourites-storage.ts @@ -0,0 +1,197 @@ +"use client"; + +export const DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY = "database:favourites:last-opened-v1"; +export const DATABASE_FAVOURITES_PINNED_STORAGE_KEY = "database:favourites:pinned-v1"; + +const DEFAULT_PINNED_ITEM_IDS = ["acamprosate-renal-screen", "lithium-monitoring-guideline"]; + +// Initial baseline offsets for demo prototype items before user interactions +function getDefaultInitialTimestamps(): Record { + const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + return { + "acamprosate-renal-screen": now - 15 * 60 * 1000, // 15 mins ago (today) + "lithium-monitoring-guideline": now - 35 * 60 * 1000, // 35 mins ago (today) + "renal-dose-search": now - 55 * 60 * 1000, // 55 mins ago (today) + "clozapine-monitoring-table": now - dayMs - 2 * 60 * 60 * 1000, // yesterday + "qt-prolongation-quote": now - 3 * dayMs, // earlier this week + }; +} + +let inMemoryLastOpened: Record | null = null; +let inMemoryPinned: Set | null = null; +const listeners = new Set<() => void>(); + +function notifyListeners() { + for (const listener of listeners) { + try { + listener(); + } catch { + // Ignore listener errors + } + } +} + +// Single shared storage handler at module level to avoid O(N²) callbacks when +// multiple subscribers are active (each per-subscriber handler would call +// notifyListeners(), firing all listeners N times per storage event). +let sharedStorageListenerAttached = false; +function ensureSharedStorageListener() { + if (sharedStorageListenerAttached || typeof window === "undefined") return; + sharedStorageListenerAttached = true; + window.addEventListener("storage", (event: StorageEvent) => { + if ( + event.key === DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY || + event.key === DATABASE_FAVOURITES_PINNED_STORAGE_KEY + ) { + inMemoryLastOpened = null; + inMemoryPinned = null; + notifyListeners(); + } + }); +} + +export function subscribeFavouritesStorage(listener: () => void): () => void { + ensureSharedStorageListener(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function loadFavouriteLastOpened(): Record { + if (typeof window === "undefined") { + return getDefaultInitialTimestamps(); + } + if (inMemoryLastOpened) return inMemoryLastOpened; + + try { + const raw = localStorage.getItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null) { + const result: Record = { ...getDefaultInitialTimestamps(), ...parsed }; + inMemoryLastOpened = result; + return result; + } + } + } catch { + // Fallback on JSON parse / storage errors + } + + const fallback = getDefaultInitialTimestamps(); + inMemoryLastOpened = fallback; + return fallback; +} + +export function recordFavouriteOpened(itemId: string, timestamp: number = Date.now()): Record { + const current: Record = { ...loadFavouriteLastOpened(), [itemId]: timestamp }; + inMemoryLastOpened = current; + if (typeof window !== "undefined") { + try { + localStorage.setItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY, JSON.stringify(current)); + } catch { + // Ignore storage write errors (e.g. quota) + } + } + notifyListeners(); + return current; +} + +export function loadFavouritePinnedIds(): Set { + if (typeof window === "undefined") { + return new Set(DEFAULT_PINNED_ITEM_IDS); + } + if (inMemoryPinned) return inMemoryPinned; + + try { + const raw = localStorage.getItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const result = new Set(parsed.filter((id): id is string => typeof id === "string")); + inMemoryPinned = result; + return result; + } + } + } catch { + // Fallback on JSON parse / storage errors + } + + const fallback = new Set(DEFAULT_PINNED_ITEM_IDS); + inMemoryPinned = fallback; + return fallback; +} + +export function toggleFavouritePinnedId(itemId: string): Set { + const current = new Set(loadFavouritePinnedIds()); + if (current.has(itemId)) { + current.delete(itemId); + } else { + current.add(itemId); + } + inMemoryPinned = current; + if (typeof window !== "undefined") { + try { + localStorage.setItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY, JSON.stringify(Array.from(current))); + } catch { + // Ignore storage write errors + } + } + notifyListeners(); + return current; +} + +const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function formatLastOpened(timestampOrLabel: number | string | undefined): string { + if (timestampOrLabel === undefined || timestampOrLabel === null) { + return "Saved"; + } + if (typeof timestampOrLabel === "string") { + return timestampOrLabel; + } + + const date = new Date(timestampOrLabel); + if (Number.isNaN(date.getTime())) { + return "Saved"; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round((today.getTime() - itemDate.getTime()) / (24 * 60 * 60 * 1000)); + + const hours = String(date.getHours()).padStart(2, "0"); + const mins = String(date.getMinutes()).padStart(2, "0"); + const timeStr = `${hours}:${mins}`; + + if (diffDays === 0) { + return `Today ${timeStr}`; + } + if (diffDays === 1) { + return `Yesterday ${timeStr}`; + } + if (diffDays >= 2 && diffDays <= 6) { + return `${dayNames[date.getDay()]} ${timeStr}`; + } + return `${date.getDate()} ${monthNames[date.getMonth()]}`; +} + +export function lastOpenedScore(lastUsed: string | number | undefined): number { + if (typeof lastUsed === "number") { + return lastUsed; + } + if (!lastUsed) return 0; + + const lower = lastUsed.toLowerCase(); + if (lower.startsWith("today")) { + const timeMatch = lastUsed.match(/(\d{1,2}):(\d{2})/); + if (timeMatch) return 1_000_000_000_000 + Number(timeMatch[1]) * 60 + Number(timeMatch[2]); + return 1_000_000_000_000; + } + if (lower.startsWith("yesterday")) return 500_000_000_000; + if (dayNames.some((d) => lower.startsWith(d.toLowerCase()))) return 100_000_000_000; + return 1_000; +} diff --git a/src/components/search/ResultFilterSheet.tsx b/src/components/search/ResultFilterSheet.tsx new file mode 100644 index 0000000000..56d3025c1c --- /dev/null +++ b/src/components/search/ResultFilterSheet.tsx @@ -0,0 +1,21 @@ +"use client"; + +export { + ResultFilterSheet, + ResultFilterTrigger, + ResultFilterFacetChips, + ResultFilterScopeSelector, + resultFilterGroup, + resultFilterFacetGroup, + isFacetGroup, + type ResultFilterOption, + type ResultFilterOptionSection, + type ResultFilterGroupKind, + type ResultFilterLensGroup, + type ResultFilterFacetGroup, + type ResultFilterGroup, + type ResultFilterScopeOption, + type ResultFilterScopeConfig, + type ResultFilterSummary, + type ResultFilterSecondaryAction, +} from "@/components/clinical-dashboard/result-filter-control"; diff --git a/tests/favourites.test.ts b/tests/favourites.test.ts new file mode 100644 index 0000000000..f69622aaa3 --- /dev/null +++ b/tests/favourites.test.ts @@ -0,0 +1,79 @@ +/** @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY, + DATABASE_FAVOURITES_PINNED_STORAGE_KEY, + formatLastOpened, + lastOpenedScore, + loadFavouriteLastOpened, + loadFavouritePinnedIds, + recordFavouriteOpened, + toggleFavouritePinnedId, +} from "@/components/favourites/favourites-storage"; + +describe("favourites storage, timestamps and pinning", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("loads default seed timestamps and records real timestamp when item is opened", () => { + const initial = loadFavouriteLastOpened(); + expect(initial["acamprosate-renal-screen"]).toBeDefined(); + expect(typeof initial["acamprosate-renal-screen"]).toBe("number"); + + const customTime = Date.now() + 5000; + recordFavouriteOpened("test-item-1", customTime); + + const updated = loadFavouriteLastOpened(); + expect(updated["test-item-1"]).toBe(customTime); + + const storedRaw = localStorage.getItem(DATABASE_FAVOURITES_LAST_OPENED_STORAGE_KEY); + expect(storedRaw).not.toBeNull(); + const parsed = JSON.parse(storedRaw!); + expect(parsed["test-item-1"]).toBe(customTime); + }); + + it("loads default pinned IDs and allows toggling pinning state with localStorage persistence", () => { + const initialPinned = loadFavouritePinnedIds(); + expect(initialPinned.has("acamprosate-renal-screen")).toBe(true); + expect(initialPinned.has("custom-unpinned-id")).toBe(false); + + toggleFavouritePinnedId("custom-unpinned-id"); + const updated = loadFavouritePinnedIds(); + expect(updated.has("custom-unpinned-id")).toBe(true); + + const storedRaw = localStorage.getItem(DATABASE_FAVOURITES_PINNED_STORAGE_KEY); + expect(storedRaw).not.toBeNull(); + const parsed = JSON.parse(storedRaw!); + expect(parsed).toContain("custom-unpinned-id"); + + toggleFavouritePinnedId("custom-unpinned-id"); + const reverted = loadFavouritePinnedIds(); + expect(reverted.has("custom-unpinned-id")).toBe(false); + }); + + it("formats timestamps into human-readable relative strings", () => { + const now = Date.now(); + const formattedNow = formatLastOpened(now); + expect(formattedNow).toMatch(/^Today \d{2}:\d{2}$/); + + const yesterday = now - 24 * 60 * 60 * 1000; + const formattedYesterday = formatLastOpened(yesterday); + expect(formattedYesterday).toMatch(/^Yesterday \d{2}:\d{2}$/); + + expect(formatLastOpened(undefined)).toBe("Saved"); + expect(formatLastOpened("Today 08:44")).toBe("Today 08:44"); + }); + + it("computes sort scores correctly prioritizing recent timestamps", () => { + const t1 = Date.now(); + const t2 = t1 - 10000; + + expect(lastOpenedScore(t1)).toBeGreaterThan(lastOpenedScore(t2)); + expect(lastOpenedScore("Today 10:00")).toBeGreaterThan(lastOpenedScore("Yesterday 10:00")); + expect(lastOpenedScore("Yesterday 10:00")).toBeGreaterThan(lastOpenedScore("Mon 10:00")); + expect(lastOpenedScore("Saved")).toBe(1000); + }); +}); diff --git a/tests/filter-contract.dom.test.tsx b/tests/filter-contract.dom.test.tsx new file mode 100644 index 0000000000..d8f58fae4b --- /dev/null +++ b/tests/filter-contract.dom.test.tsx @@ -0,0 +1,163 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ResultFilterSheet, + resultFilterFacetGroup, + resultFilterGroup, + type ResultFilterOption, +} from "@/components/search/ResultFilterSheet"; + +afterEach(() => { + cleanup(); +}); + +describe("filter contract and density rendering", () => { + it("renders facet groups with <= 5 options as compact wrapping chips", () => { + const onToggle = vi.fn(); + const group = resultFilterFacetGroup({ + id: "small-facet", + label: "Category", + selected: new Set(["a"]), + options: [ + { value: "a", label: "Option A", hint: "3" }, + { value: "b", label: "Option B", hint: "5" }, + { value: "c", label: "Option C", hint: "2" }, + ], + onToggle, + }); + + render( + , + ); + + const buttonA = screen.getByRole("button", { name: /^Option A/ }); + expect(buttonA).toHaveAttribute("aria-pressed", "true"); + expect(buttonA.className).toContain("inline-flex"); + expect(buttonA.className.split(/\s+/)).not.toContain("w-full"); + expect(buttonA.className).toContain("min-h-tap"); + }); + + it("renders facet groups with 6–20 options as dense full-width vertical list with right-aligned counts", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + const options: ResultFilterOption[] = Array.from({ length: 9 }, (_, i) => ({ + value: `option-${i + 1}`, + label: `Domain ${i + 1}`, + hint: `${(i + 1) * 2}`, + })); + + const group = resultFilterFacetGroup({ + id: "dense-domains", + label: "Domains", + selected: new Set(["option-2"]), + options, + onToggle, + }); + + render( + , + ); + + const groupEl = screen.getByRole("group", { name: "Domains" }); + expect(groupEl).toBeInTheDocument(); + expect(groupEl.className).toContain("grid"); + + const buttons = within(groupEl).getAllByRole("button"); + expect(buttons).toHaveLength(9); + + const firstButton = buttons[0]; + expect(firstButton.className).toContain("w-full"); + expect(firstButton.className).toContain("justify-between"); + expect(firstButton.className).toContain("min-h-tap"); + expect(firstButton).toHaveAttribute("aria-pressed", "false"); + + const secondButton = buttons[1]; + expect(secondButton).toHaveAttribute("aria-pressed", "true"); + + await user.click(firstButton); + expect(onToggle).toHaveBeenCalledWith("option-1"); + }); + + it("adds find-a-filter and disclosure header when total facet options exceed 20 or facet groups exceed 3", () => { + const onToggle = vi.fn(); + const groups = Array.from({ length: 4 }, (_, groupIndex) => + resultFilterFacetGroup({ + id: `facet-group-${groupIndex + 1}`, + label: `Group ${groupIndex + 1}`, + selected: new Set(), + options: [ + { value: `g${groupIndex}-1`, label: `Item 1`, hint: "1" }, + { value: `g${groupIndex}-2`, label: `Item 2`, hint: "2" }, + ], + onToggle, + }), + ); + + render( + , + ); + + expect(screen.getByTestId("super-dense-panel-find")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Group 1/ })).toHaveAttribute("aria-expanded", "false"); + }); + + it("handles roving radio selection for lens groups", () => { + const onChange = vi.fn(); + const lensGroup = resultFilterGroup({ + id: "view-lens", + label: "View", + value: "all", + options: [ + { value: "all", label: "All items" }, + { value: "presentations", label: "Presentations" }, + { value: "diagnoses", label: "Diagnoses" }, + ], + onChange, + }); + + render( + , + ); + + const radioAll = screen.getByRole("radio", { name: "All items" }); + expect(radioAll).toHaveAttribute("aria-checked", "true"); + expect(radioAll).toHaveAttribute("tabindex", "0"); + + const radioPres = screen.getByRole("radio", { name: "Presentations" }); + expect(radioPres).toHaveAttribute("aria-checked", "false"); + expect(radioPres).toHaveAttribute("tabindex", "-1"); + + fireEvent.click(radioPres); + expect(onChange).toHaveBeenCalledWith("presentations"); + }); +}); diff --git a/tests/filter-contract.test.ts b/tests/filter-contract.test.ts new file mode 100644 index 0000000000..05368331a6 --- /dev/null +++ b/tests/filter-contract.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("filter contract structural verification", () => { + it("exports ResultFilterSheet from src/components/search/ResultFilterSheet.tsx", () => { + const filterExport = source("src/components/search/ResultFilterSheet.tsx"); + expect(filterExport).toContain("ResultFilterSheet"); + expect(filterExport).toContain("ResultFilterTrigger"); + expect(filterExport).toContain("ResultFilterFacetChips"); + }); + + it("implements density tier detection in result-filter-control.tsx", () => { + const control = source("src/components/clinical-dashboard/result-filter-control.tsx"); + expect(control).toContain("isDenseList"); + expect(control).toContain("group.options.length >= 6"); + expect(control).toContain("group.options.length <= 20"); + expect(control).toContain("min-h-tap"); + }); + + it("documents density tiers in docs/filter-contract.md", () => { + const doc = source("docs/filter-contract.md"); + expect(doc).toContain("6–20 options"); + expect(doc).toContain("dense full-width vertical list"); + expect(doc).toContain("≤ 5 options"); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 65682065d5..645269dd67 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4886,7 +4886,12 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(passages.nth(0)).toHaveJSProperty("open", false); await clickSectionNav(/High-yield summary/); - await expect(summary).toHaveJSProperty("open", true); + // At this 390px viewport the rail's high-yield-summary disclosure is + // hidden (superseded by the in-flow DocumentClinicalSummary card), so + // there is nothing for the exclusive accordion to open here — + // jumpToDocumentSection scrolls to the visible copy instead. + await expect(page.locator("#source-summary-card")).toBeInViewport(); + await expect(summary).toHaveJSProperty("open", false); await expect(indexedText).toHaveJSProperty("open", false); await openImagesDisclosure();