From 59ced590a932e1c7fe28f26a94eb05105ce0e4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:50:20 +0000 Subject: [PATCH 1/4] feat(dsm): compact category dropdown filter, more prominent results Replace the multi-row wall of category filter pills on the DSM search results page with a single compact dropdown menu, and give the results list a prominent header band so it dominates the page. - Add CategoryFilterDropdown: an anchored menuitemradio menu of category links (server-driven filtering preserved) with the active category shown as the checked option. Keyboard support (arrow/Home/End/Escape), outside-click + Escape dismissal via useDismissableLayer, and roving focus across options. - Collapse the filter into one toolbar row with a "Clear filter" affordance when a category is active, reclaiming ~3 rows of vertical space so results sit higher. - Add a "Matching diagnoses / N results" header band to the results card to anchor and emphasise the result list. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VnTrYkS5MjsB9JoA2bF4TH --- src/components/dsm/dsm-search-page.tsx | 246 ++++++++++++++++++++----- 1 file changed, 202 insertions(+), 44 deletions(-) diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index d90d00b4e3..ad22d63d6a 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -1,11 +1,22 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; -import { BookOpenCheck, Check, ChevronRight, CircleAlert, GitCompareArrows, ListFilter, SearchX } from "lucide-react"; +import { useEffect, useId, useMemo, useRef, useState, 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 +33,172 @@ 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), + }); + + useEffect(() => { + if (!open) return undefined; + const target = Math.max(0, activeIndex); + const frame = window.requestAnimationFrame(() => optionRefs.current[target]?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [open, activeIndex]); + + function focusOption(index: number) { + const total = options.length; + const next = ((index % total) + total) % total; + optionRefs.current[next]?.focus(); + } + + function handleTriggerKeyDown(event: ReactKeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + window.requestAnimationFrame(() => focusOption(Math.max(0, activeIndex))); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + window.requestAnimationFrame(() => focusOption(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 === "Escape") { + event.preventDefault(); + setOpen(false); + window.requestAnimationFrame(() => triggerRef.current?.focus()); + } + } + + const activeLabel = activeCategory ? activeCategory.label : "All categories"; + const activeCount = activeCategory ? activeCategory.diagnosis_count : totalCount; + + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + export function DsmSearchPage({ query, category, @@ -77,53 +254,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 +282,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 From 0ff0179c6edb2e2f73682b367d9e33dc1f0555bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:06:09 +0000 Subject: [PATCH 2/4] fix(dsm): correct keyboard focus in category filter dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two keyboard-interaction defects in the new CategoryFilterDropdown raised in review: - ArrowUp reverse-entry: a focus-on-open effect and the trigger key handler both scheduled focus when the menu opened, racing each other so ArrowUp could land on the active item instead of the last option. Collapse to a single source of truth — openMenu(focusIndex) picks the target and schedules the one focus call. - Tab focus-out: the menu stayed open (aria-expanded true) after a keyboard user tabbed off the last option, letting it linger over the results. Add a root focus-out handler that closes only when focus leaves the widget, without preventing the focus move or closing while focus travels between the trigger and its options. Add a targeted ui-smoke test covering ArrowDown/ArrowUp entry points, Escape restore-to-trigger, and Tab-to-close. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VnTrYkS5MjsB9JoA2bF4TH --- src/components/dsm/dsm-search-page.tsx | 45 ++++++++++++++++++-------- tests/ui-smoke.spec.ts | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index ad22d63d6a..370184a023 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -1,7 +1,14 @@ "use client"; import Link from "next/link"; -import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { + useId, + useMemo, + useRef, + useState, + type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { BookOpenCheck, Check, @@ -72,28 +79,28 @@ function CategoryFilterDropdown({ onDismiss: () => setOpen(false), }); - useEffect(() => { - if (!open) return undefined; - const target = Math.max(0, activeIndex); - const frame = window.requestAnimationFrame(() => optionRefs.current[target]?.focus()); - return () => window.cancelAnimationFrame(frame); - }, [open, activeIndex]); - 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(); - setOpen(true); - window.requestAnimationFrame(() => focusOption(Math.max(0, activeIndex))); + openMenu(Math.max(0, activeIndex)); } else if (event.key === "ArrowUp") { event.preventDefault(); - setOpen(true); - window.requestAnimationFrame(() => focusOption(options.length - 1)); + openMenu(options.length - 1); } } @@ -117,11 +124,21 @@ function CategoryFilterDropdown({ } } + // 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 ( -
+
-

+

{result.title} -

+

{result.summary}

diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index ab6054aaf5..dc6928d421 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2374,28 +2374,30 @@ test.describe("Clinical KB UI smoke coverage", () => { const trigger = page.getByTestId("dsm-category-filter"); const options = page.getByRole("menuitemradio"); - // ArrowDown opens the menu with focus on the active option ("All categories"). + // 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("ArrowDown"); - await expect(options.first()).toBeFocused(); - await expect(options.first()).toHaveAttribute("aria-checked", "true"); + 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(); - // 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 page.keyboard.press("ArrowUp"); - await expect(options.last()).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"); - // Tabbing out of the widget closes the menu instead of leaving it open over - // the results. + // 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); }); test("dashboard specifiers mode param redirects to the standalone specifiers route", async ({ page }) => { From 0241389bdf8c1925a445f7b553bd86b12e36ec03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:20:27 +0000 Subject: [PATCH 4/4] fix(dsm): activate category options on Space key Options are exposed as menuitemradio, where Space is an expected activation key, but the underlying element is an anchor (Space scrolls / does nothing). Handle Space in the option key handler with preventDefault() and route it through the same activation path as click/Enter so keyboard users can apply the announced radio menu item. Extend the ui-smoke keyboard test to assert Space navigates to the focused category. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VnTrYkS5MjsB9JoA2bF4TH --- src/components/dsm/dsm-search-page.tsx | 5 +++++ tests/ui-smoke.spec.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 16310971ec..8bff19b408 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -117,6 +117,11 @@ function CategoryFilterDropdown({ } 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. diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index dc6928d421..3efd3a724d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2398,6 +2398,16 @@ test.describe("Clinical KB UI smoke coverage", () => { 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 }) => {