From 3d5c7880a3f04f163f9c5e509836a93528554762 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:22:38 +0800 Subject: [PATCH 1/3] fix(search): keep command panel closed on phones --- .../universal-search-command-surface.tsx | 41 +++++++++++++++---- src/lib/search-command-surface.ts | 13 ++++++ tests/search-command-surface.test.ts | 10 +++++ tests/ui-tools.spec.ts | 19 ++++----- 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index b52b97a04..fa0ea94ea 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -21,10 +21,12 @@ import { cn } from "@/components/ui-primitives"; import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; import { appModeIcons } from "@/lib/app-mode-icons"; import { + commandDropdownDisplayMediaQuery, differentialRedFlagTerms, filteredSuggestions, isFormCodeQuery, searchCommandSurfaceConfig, + type CommandSurfacePlacement, } from "@/lib/search-command-surface"; import type { UniversalSearchDomain } from "@/lib/universal-search"; import { universalSearchModeForDomain } from "@/lib/universal-search-mode-context"; @@ -146,8 +148,6 @@ function OptionShell({ active, children, hint }: { active: boolean; children: Re ); } -export type CommandSurfacePlacement = "bottom-dock" | "inline"; - function SmartRotatingHint({ examples, modeLabel }: { examples: string[]; modeLabel: string }) { const [activeExampleIndex, setActiveExampleIndex] = useState(0); const activeExample = examples[activeExampleIndex % examples.length]; @@ -220,7 +220,7 @@ function CommandDropdown({ // template, so without it the section headings inherit text-center. "universal-command-dropdown absolute left-0 right-0 z-30 overflow-hidden rounded-2xl border border-[color:var(--border-strong)] bg-[color:var(--surface)] text-left shadow-[var(--shadow-elevated)]", opensUpward ? "bottom-[calc(100%+0.5rem)] top-auto" : "top-[calc(100%+0.5rem)]", - "block max-sm:rounded-xl", + placement === "bottom-dock" ? "hidden sm:block" : "hidden lg:block", )} role="presentation" > @@ -380,11 +380,30 @@ export function UniversalSearchCommandSurface({ const [activeIndex, setActiveIndex] = useState(-1); const trimmedQuery = query.trim(); const mode = appModeDefinition(modeId); + // The dropdown is a fine-pointer desktop enhancement. Width-only checks let + // wide, zoomed, or desktop-mode phones open it over the page. + const dropdownMediaQuery = commandDropdownDisplayMediaQuery(placement); + const [dropdownDisplayable, setDropdownDisplayable] = useState( + () => typeof window !== "undefined" && window.matchMedia(dropdownMediaQuery).matches, + ); + useEffect(() => { + const mediaQuery = window.matchMedia(dropdownMediaQuery); + const sync = () => { + setDropdownDisplayable(mediaQuery.matches); + if (!mediaQuery.matches) { + onDropdownOpenChange(false); + setActiveIndex(-1); + } + }; + sync(); + mediaQuery.addEventListener("change", sync); + return () => mediaQuery.removeEventListener("change", sync); + }, [dropdownMediaQuery, onDropdownOpenChange]); // A true "everything" view: the active mode's own domain is included (no excludeDomain) so // the palette surfaces every entity type, ordered by the server's intent-aware domainOrder. const universal = useUniversalSearch({ query: trimmedQuery, - enabled: dropdownOpen && Boolean(config), + enabled: dropdownOpen && dropdownDisplayable && Boolean(config), contextMode: modeId, }); const savedRegistryFavourites = useSavedRegistryFavourites(); @@ -850,6 +869,14 @@ export function UniversalSearchCommandSurface({ const activeItemId = activeIndex >= 0 && activeIndex < flatItems.length ? flatItems[activeIndex].id : null; function handleComposerKeyDown(event: ReactKeyboardEvent) { + if (!dropdownDisplayable) { + if (event.key === "Escape") { + onDropdownOpenChange(false); + setActiveIndex(-1); + } + onInputKeyDown?.(event); + return; + } if (event.key === "ArrowDown") { event.preventDefault(); onDropdownOpenChange(true); @@ -946,7 +973,7 @@ export function UniversalSearchCommandSurface({ } }} onFocusCapture={() => { - onDropdownOpenChange(true); + if (dropdownDisplayable) onDropdownOpenChange(true); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { @@ -956,7 +983,7 @@ export function UniversalSearchCommandSurface({ }} > {children} - {dropdownOpen ? ( + {dropdownOpen && dropdownDisplayable ? ( { onQueryChange(example); - onDropdownOpenChange(true); + if (dropdownDisplayable) onDropdownOpenChange(true); onFocusSearchInput?.(); }} /> diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index f387fa44c..62fe52df0 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -19,6 +19,19 @@ export type SearchCommandSurfaceConfig = { crossModes: AppModeId[]; }; +export type CommandSurfacePlacement = "bottom-dock" | "inline"; + +/** + * The command panel is a desktop enhancement. Width alone is not enough to + * identify that environment: phones can report a wide viewport in landscape, + * display-zoom, or desktop-site modes. Requiring a hover-capable fine pointer + * keeps the panel closed on touch-first phones while preserving it for desktop. + */ +export function commandDropdownDisplayMediaQuery(placement: CommandSurfacePlacement) { + const minimumWidth = placement === "bottom-dock" ? "640px" : "1024px"; + return `(min-width: ${minimumWidth}) and (hover: hover) and (pointer: fine)`; +} + const searchCommandSurfaceByMode: Partial> = { documents: { examples: ["clozapine ANC thresholds", "lithium monitoring table", "QT prolongation quote"], diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index 9615d0589..402b65e30 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + commandDropdownDisplayMediaQuery, differentialRedFlagTerms, favouriteMatchesCommandScopes, filteredSuggestions, @@ -28,6 +29,15 @@ function serviceRecord(overrides: Partial = {}): ServiceRecord { } describe("search command surface", () => { + it("requires desktop pointer capabilities before displaying the command dropdown", () => { + expect(commandDropdownDisplayMediaQuery("bottom-dock")).toBe( + "(min-width: 640px) and (hover: hover) and (pointer: fine)", + ); + expect(commandDropdownDisplayMediaQuery("inline")).toBe( + "(min-width: 1024px) and (hover: hover) and (pointer: fine)", + ); + }); + it("returns mode-specific command surface config", () => { const documents = searchCommandSurfaceConfig("documents"); expect(documents?.examples.length).toBeGreaterThan(0); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index ef26aea94..7b7d1ae26 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -662,23 +662,20 @@ test.describe("Clinical KB tools launcher", () => { } }); - test("phone bottom-dock search opens the bounded command results sheet", async ({ page }) => { + test("phone bottom-dock search keeps the command results sheet hidden", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible(); const input = visibleGlobalSearchInput(page).first(); await expect(input).toBeVisible(); - // Phone searches use the same accessible listbox as larger viewports, bounded - // above the bottom dock so results stay reachable without horizontal overflow. + // Phones keep the full search results in the page instead of opening a + // command sheet over the small viewport. await input.click(); await input.press("ArrowDown"); - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1); - await expect(page.getByRole("listbox", { name: "Services search suggestions" })).toBeVisible(); - await expectNoPageHorizontalOverflow(page); - - await page.evaluate(() => window.dispatchEvent(new Event("scroll"))); await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.getByRole("listbox", { name: "Services search suggestions" })).toHaveCount(0); + await expectNoPageHorizontalOverflow(page); }); test("phone mode homes keep the shared search in the hero, not the bottom dock", async ({ page }) => { @@ -697,11 +694,11 @@ test.describe("Clinical KB tools launcher", () => { expect(geometry.top).toBeGreaterThan(0); expect(geometry.bottom).toBeLessThan(geometry.viewportHeight - 40); - // Hero composers expose the same bounded universal results sheet on phones. + // Phone hero composers must not cover the page with the universal sheet. await heroInput.click(); await heroInput.press("ArrowDown"); - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(1); - await expect(page.getByRole("listbox")).toBeVisible(); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.getByRole("listbox")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); } }); From 1faaf4d3614d04842cb3e2faacb605b48d2cd3ef Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:59:13 +0800 Subject: [PATCH 2/3] test(search): align phone typeahead contract --- tests/ui-universal-search.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 821fa4176..9f638f100 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -190,14 +190,15 @@ test.describe("universal search typeahead", () => { await expect(page.getByText("Saved").first()).toBeVisible(); }); - test("shows cross-mode typeahead on a phone viewport", async ({ page }) => { + test("keeps cross-mode typeahead hidden on a phone viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockUniversalSearch(page); const input = await openComposer(page); await input.fill("acamprosate"); - await expect(page.getByText(/Current mode · Documents · 1/)).toBeVisible(); - await expect(page.getByText("Also in Medication · Medications · 1")).toBeVisible(); + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.getByText(/Current mode · Documents · 1/)).toHaveCount(0); + await expect(page.getByText("Also in Medication · Medications · 1")).toHaveCount(0); }); test("keeps compact cross-mode matches visible after submission", async ({ page }) => { From 590f32b73815fd5db0784762e4f9e3322e338107 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:45:01 +0800 Subject: [PATCH 3/3] test(search): cover wide touch phone suppression --- .../universal-search-command-surface.tsx | 5 ++-- tests/ui-universal-search.spec.ts | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index fa0ea94ea..fc925f4a3 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -383,10 +383,9 @@ export function UniversalSearchCommandSurface({ // The dropdown is a fine-pointer desktop enhancement. Width-only checks let // wide, zoomed, or desktop-mode phones open it over the page. const dropdownMediaQuery = commandDropdownDisplayMediaQuery(placement); - const [dropdownDisplayable, setDropdownDisplayable] = useState( - () => typeof window !== "undefined" && window.matchMedia(dropdownMediaQuery).matches, - ); + const [dropdownDisplayable, setDropdownDisplayable] = useState(false); useEffect(() => { + if (typeof window.matchMedia !== "function") return; const mediaQuery = window.matchMedia(dropdownMediaQuery); const sync = () => { setDropdownDisplayable(mediaQuery.matches); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 9f638f100..068a39661 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -190,15 +190,25 @@ test.describe("universal search typeahead", () => { await expect(page.getByText("Saved").first()).toBeVisible(); }); - test("keeps cross-mode typeahead hidden on a phone viewport", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await mockUniversalSearch(page); - const input = await openComposer(page); - await input.fill("acamprosate"); - - await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); - await expect(page.getByText(/Current mode · Documents · 1/)).toHaveCount(0); - await expect(page.getByText("Also in Medication · Medications · 1")).toHaveCount(0); + test("keeps cross-mode typeahead hidden on a landscape touch phone", async ({ browser, baseURL }) => { + const context = await browser.newContext({ + ...(baseURL ? { baseURL } : {}), + hasTouch: true, + viewport: { width: 844, height: 390 }, + }); + const page = await context.newPage(); + + try { + await mockUniversalSearch(page); + const input = await openComposer(page); + await input.fill("acamprosate"); + + await expect(page.locator(".universal-command-dropdown:visible")).toHaveCount(0); + await expect(page.getByText(/Current mode · Documents · 1/)).toHaveCount(0); + await expect(page.getByText("Also in Medication · Medications · 1")).toHaveCount(0); + } finally { + await context.close(); + } }); test("keeps compact cross-mode matches visible after submission", async ({ page }) => {