Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -3366,6 +3366,7 @@ export function ClinicalDashboard({
composerFollowUpSuggestions={searchMode === "answer" ? answerFollowUpSuggestions : undefined}
onPickComposerFollowUpSuggestion={handlePickFollowUpSuggestion}
composerFollowUpSuggestionsDisabled={loading}
showPhoneSuggestionTickerOnHome={heroOwnsPhoneComposer}
sharedHomeIdentity={showSharedHome}
composerPlaceholder={searchMode === "answer" && latestAnswerQuery ? "Ask a follow-up..." : undefined}
mobileSearchPlacement={hasMobileBottomSearch ? "bottom" : "default"}
Expand Down
1 change: 1 addition & 0 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -877,6 +877,7 @@ function GlobalStandaloneSearchShellBody({
differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined
}
desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"}
showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"}
searchComposerVisible={shouldShowSearchComposer}
desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined}
desktopPageComposerSlotId={
Expand Down
4 changes: 4 additions & 0 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -195,6 +195,7 @@ export function MasterSearchHeader({
mobileBottomSearchVariant = "default",
desktopSearchPlacement = "default",
searchComposerVisible = true,
showPhoneSuggestionTickerOnHome = false,
desktopHomeComposerSlotId,
desktopPageComposerSlotId,
heroComposerBreakpoint = "all",
Expand DownExpand Up@@ -250,6 +251,8 @@ export function MasterSearchHeader({
* content keeps maximum screen space. Every phone dock uses it now; the
* "default" value remains for hosts that need the taller legacy dock. */
mobileBottomSearchVariant?: "default" | "compact";
/** Show the compact phone suggestion ticker only for standalone-mode homes. */
showPhoneSuggestionTickerOnHome?: boolean;
desktopSearchPlacement?: "default" | "hero";
searchComposerVisible?: boolean;
/** Mode-home slot the composer portals into so the search pill sits in the
Expand DownExpand Up@@ -1816,6 +1819,7 @@ export function MasterSearchHeader({
onListboxIdReady={setCommandListboxId}
onActiveItemIdChange={setCommandActiveItemId}
onFocusSearchInput={handleFocusSearchInput}
showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome}
>
<div
data-menu-placement={actionMenuOpen ? actionMenuPlacement : undefined}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,15 @@

import { TriangleAlert, Clock, CornerDownLeft, Heart, Loader2, Search, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useId, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";

import {
modeActionItemsFor,
Expand DownExpand Up@@ -160,28 +168,86 @@ function OptionShell({ active, children, hint }: { active: boolean; children: Re
);
}

function SmartRotatingHint({ examples, modeLabel }: { examples: string[]; modeLabel: string }) {
function SmartRotatingHint({
examples,
modeLabel,
showPhoneTicker,
onPickExample,
}: {
examples: string[];
modeLabel: string;
showPhoneTicker: boolean;
onPickExample: (example: string) => void;
}) {
const [activeExampleIndex, setActiveExampleIndex] = useState(0);
const [heldTickerExample, setHeldTickerExample] = useState<string | null>(null);
const [isTickerHeld, setIsTickerHeld] = useState(false);
const activeExample = examples[activeExampleIndex % examples.length];

useEffect(() => {
if (isTickerHeld) {
return;
}

if (examples.length <= 1) return;
const intervalId = window.setInterval(() => {
setActiveExampleIndex((current) => (current + 1) % examples.length);
}, SMART_HINT_ROTATION_MS);
return () => window.clearInterval(intervalId);
}, [examples]);
}, [examples, isTickerHeld]);

const freezeTicker = useCallback(() => {
setHeldTickerExample(activeExample);
setIsTickerHeld(true);
}, [activeExample]);

const releaseTicker = useCallback(() => {
setIsTickerHeld(false);
}, []);

const resolvedTickerExample = isTickerHeld ? (heldTickerExample ?? activeExample) : activeExample;

if (!activeExample) return null;

return (
<div data-testid="smart-search-rotating-text" className="smart-search-rotating-text" aria-live="polite">
<span>Smart search</span>
<span aria-hidden="true">·</span>
<span>
Try <span className="smart-search-rotating-query">&ldquo;{activeExample}&rdquo;</span> in {modeLabel}.
</span>
</div>
<>
<div data-testid="smart-search-rotating-text" className="smart-search-rotating-text" aria-live="polite">
<span>Smart search</span>
<span aria-hidden="true">·</span>
<span>
Try <span className="smart-search-rotating-query">&ldquo;{activeExample}&rdquo;</span> in {modeLabel}.
</span>
</div>
{showPhoneTicker ? (
<button
type="button"
data-testid="smart-search-phone-ticker"
className="smart-search-phone-ticker"
onClick={() => onPickExample(resolvedTickerExample)}
onFocus={freezeTicker}
onBlur={releaseTicker}
onMouseEnter={freezeTicker}
onMouseLeave={releaseTicker}
onPointerDown={freezeTicker}
onPointerUp={releaseTicker}
onPointerLeave={releaseTicker}
onPointerCancel={releaseTicker}
onTouchStart={freezeTicker}
onTouchEnd={releaseTicker}
onTouchCancel={releaseTicker}
aria-label={`Try suggested search: ${resolvedTickerExample}`}
>
<span className="smart-search-phone-ticker-kicker">
<Sparkles aria-hidden="true" className="size-icon-sm" />
Try this
</span>
<span className="smart-search-phone-ticker-query">{resolvedTickerExample}</span>
<span className="smart-search-phone-ticker-action" aria-hidden="true">
Tap to search
</span>
</button>
) : null}
</>
);
}

Expand DownExpand Up@@ -364,6 +430,7 @@ export function UniversalSearchCommandSurface({
onFocusSearchInput,
onListboxIdReady,
onActiveItemIdChange,
showPhoneSuggestionTicker = false,
placement = "inline",
children,
}: {
Expand All@@ -384,6 +451,8 @@ export function UniversalSearchCommandSurface({
onFocusSearchInput?: () => void;
onListboxIdReady?: (listboxId: string) => void;
onActiveItemIdChange?: (activeItemId: string | null) => void;
/** Show the compact, tappable suggestion ticker below an in-flow phone home composer. */
showPhoneSuggestionTicker?: boolean;
placement?: CommandSurfacePlacement;
children: ReactNode;
}) {
Expand DownExpand Up@@ -992,7 +1061,15 @@ export function UniversalSearchCommandSurface({
placement === "bottom-dock" ? "gap-1" : "gap-2",
)}
>
<SmartRotatingHint examples={config.examples} modeLabel={mode.label} />
<SmartRotatingHint
examples={config.examples}
modeLabel={mode.label}
showPhoneTicker={showPhoneSuggestionTicker}
onPickExample={(example) => {
onQueryChange(example);
onFocusSearchInput?.();
}}
/>
<div
className="relative w-full"
onKeyDownCapture={(event) => {
Expand Down
33 changes: 32 additions & 1 deletion tests/ui-overlap.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -266,12 +266,43 @@ test.describe("Header element overlap coverage", () => {
);
});

test("phone smart search does not show the desktop rotating text or prompt row", async ({ page }) => {
test("phone smart search replaces desktop rows with one tappable ticker", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoDashboard(page);
await gotoHome(page);

await expect(page.getByTestId("smart-search-rotating-text")).toBeHidden();
await expect(page.getByTestId("smart-search-prompt-row")).toBeHidden();

const ticker = page.getByTestId("smart-search-phone-ticker");
await expect(ticker).toBeVisible();
await expect(ticker).toContainText("Try this");
await expect(ticker).toContainText("Tap to search");

const tickerBox = await ticker.boundingBox();
expect(tickerBox, "phone suggestion ticker must render").not.toBeNull();
expect(tickerBox!.height, "phone ticker must meet the tap-target floor").toBeGreaterThanOrEqual(48);

const suggestion = (await ticker.getAttribute("aria-label"))?.replace("Try suggested search: ", "");
expect(suggestion).toBeTruthy();
await ticker.click();
await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toHaveValue(suggestion ?? "");
});

test("phone suggestion ticker renders on /documents mode home", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 820 });
await mockDemoDashboard(page);
await page.goto("/documents", { waitUntil: "domcontentloaded" });
await expect(async () => {
const header = page.locator("header#search");
await expect(header).toHaveCount(1);
await expect(header).toBeVisible();
}).toPass({ timeout: 30_000 });

const ticker = page.getByTestId("smart-search-phone-ticker");
await expect(ticker).toBeVisible();
const tickerBox = await ticker.boundingBox();
expect(tickerBox, "phone suggestion ticker must render on /documents home").not.toBeNull();
expect(tickerBox!.height, "phone ticker must meet the tap-target floor on /documents").toBeGreaterThanOrEqual(48);
});
});
Loading