diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index d90d00b4e3..8bff19b408 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -1,11 +1,29 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; -import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, ListFilter, SearchX } from "lucide-react"; +import { + useId, + useMemo, + useRef, + useState, + type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { + BookOpenCheck, + Check, + ChevronDown, + ChevronRight, + CircleAlert, + GitCompareArrows, + ListFilter, + SearchX, + X, +} from "lucide-react"; import { DsmPageHeader } from "@/components/dsm/dsm-page-header"; -import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; +import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { cn, codeText, metadataPill, pageContainer, searchFocusRing } from "@/components/ui-primitives"; import type { DsmCategory, DsmDiagnosisSummary } from "@/lib/dsm"; function categoryHref(query: string, category?: string, ids: string[] = []) { @@ -22,6 +40,189 @@ function compareHref(slugs: string[]) { return `/dsm/compare?${params.toString()}`; } +// Compact category filter: a single trigger that opens an anchored menu of +// category links, replacing the multi-row pill wall so results sit higher on the +// page. Each option is a real navigation link (server-driven filtering), styled as +// a menuitemradio so the active category reads as the checked option. +function CategoryFilterDropdown({ + query, + categories, + activeCategory, + totalCount, + selected, +}: { + query: string; + categories: DsmCategory[]; + activeCategory?: DsmCategory; + totalCount: number; + selected: string[]; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef>([]); + const menuId = useId(); + + const options = useMemo( + () => [ + { key: undefined as string | undefined, label: "All categories", count: totalCount }, + ...categories.map((item) => ({ key: item.key, label: item.label, count: item.diagnosis_count })), + ], + [categories, totalCount], + ); + const activeIndex = activeCategory ? options.findIndex((option) => option.key === activeCategory.key) : 0; + + useDismissableLayer({ + enabled: open, + refs: [rootRef], + restoreFocusRef: triggerRef, + onDismiss: () => setOpen(false), + }); + + function focusOption(index: number) { + const total = options.length; + const next = ((index % total) + total) % total; + optionRefs.current[next]?.focus(); + } + + // Single source of truth for initial focus: whoever opens the menu picks the + // option to land on and schedules the one focus call. A parallel open-effect + // that also focused the active item would race this and clobber ArrowUp's + // reverse-entry onto the last option. + function openMenu(focusIndex: number) { + setOpen(true); + window.requestAnimationFrame(() => focusOption(focusIndex)); + } + + function handleTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + openMenu(Math.max(0, activeIndex)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + openMenu(options.length - 1); + } + } + + function handleOptionKeyDown(event: ReactKeyboardEvent, index: number) { + if (event.key === "ArrowDown") { + event.preventDefault(); + focusOption(index + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + focusOption(index - 1); + } else if (event.key === "Home") { + event.preventDefault(); + focusOption(0); + } else if (event.key === "End") { + event.preventDefault(); + focusOption(options.length - 1); + } else if (event.key === " ") { + // A menuitemradio announces Space as an activation key, but the option is + // an anchor (Space would otherwise scroll), so activate it like click/Enter. + event.preventDefault(); + event.currentTarget.click(); + } + // Escape (dismiss + restore focus to the trigger) is owned by + // useDismissableLayer's document-level handler, so it isn't duplicated here. + } + + // Close when focus leaves the widget entirely (e.g. Tab off the last option), + // so the menu never lingers open over the results. Keep it open while focus + // moves between the trigger and its options, and don't prevent the focus move. + function handleRootBlur(event: ReactFocusEvent) { + if (!open) return; + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && rootRef.current?.contains(nextTarget)) return; + setOpen(false); + } + + const activeLabel = activeCategory ? activeCategory.label : "All categories"; + const activeCount = activeCategory ? activeCategory.diagnosis_count : totalCount; + + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + export function DsmSearchPage({ query, category, @@ -77,53 +278,26 @@ export function DsmSearchPage({ />
-
-
-

- - Filter by category -

- {activeCategory ? ( - - Clear filter - - ) : null} -
-
+
+ + {activeCategory ? ( - All · {totalCount} + + Clear filter - {categories.map((item) => ( - - {item.label} · {item.diagnosis_count} - - ))} -
+ ) : null}
{results.length ? ( @@ -132,6 +306,14 @@ export function DsmSearchPage({ aria-label="DSM diagnosis results" className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-soft)]" > +
+

+ {query ? "Matching diagnoses" : "Diagnosis catalogue"} +

+ + {results.length} {results.length === 1 ? "result" : "results"} + +
Select Diagnosis @@ -174,9 +356,9 @@ export function DsmSearchPage({ )} -

+

{result.title} -

+

{result.summary}

diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index c7f158ab61..3efd3a724d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2365,6 +2365,51 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("DSM category filter dropdown opens to the correct option by keyboard", async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 850 }); + await mockDemoApi(page); + await gotoApp(page, "/dsm/search?q=depression"); + + await expect(page.getByTestId("dsm-search-page")).toBeVisible(); + const trigger = page.getByTestId("dsm-category-filter"); + const options = page.getByRole("menuitemradio"); + + // ArrowUp opens the menu with focus on the LAST option (reverse entry). This + // guards against a regression where a competing focus-on-open effect raced + // the key handler and stole focus back to the active item. + await trigger.focus(); + await page.keyboard.press("ArrowUp"); + await expect(options.last()).toBeFocused(); + + // Escape closes the menu and restores focus to the trigger. + await page.keyboard.press("Escape"); + await expect(options.first()).toBeHidden(); + await expect(trigger).toBeFocused(); + + // ArrowDown opens the menu with focus on the active option ("All categories"). + await page.keyboard.press("ArrowDown"); + await expect(options.first()).toBeFocused(); + await expect(options.first()).toHaveAttribute("aria-checked", "true"); + + // Options sit outside the Tab sequence (tabIndex=-1), so one Tab press from a + // non-final option leaves the whole widget in a single step and closes the + // menu instead of stepping through every category link. + await page.keyboard.press("Tab"); + await expect(options.first()).toBeHidden(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(options).toHaveCount(0); + + // Space activates the focused option (announced as a menuitemradio) even + // though the underlying element is an anchor, applying the category filter. + await trigger.focus(); + await page.keyboard.press("ArrowDown"); + await expect(options.first()).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(options.nth(1)).toBeFocused(); + await page.keyboard.press("Space"); + await expect(page).toHaveURL(/[?&]category=/); + }); + test("dashboard specifiers mode param redirects to the standalone specifiers route", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page);