diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 5f61717b0..81a6ce6f0 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -14,7 +14,7 @@ import type * as React from "react"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import { cn } from "@/shared/lib/cn"; -import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout"; +import { channelChrome } from "@/shared/layout/chromeLayout"; type ChatHeaderProps = { actions?: React.ReactNode; @@ -151,7 +151,6 @@ export function ChatHeader({ ref={chromeWrapperRef} className={cn( "pointer-events-none relative z-30 bg-background/80 backdrop-blur-md after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border/35 after:content-[''] supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55", - topChromeInset.padding, channelChrome.negativeMargin, )} > diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 4b77ba56b..da7452012 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -15,15 +15,14 @@ import { import type { Channel, SearchHit } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { - POPOVER_CUSTOM_ENTER_MOTION_CLASS, - POPOVER_SHADOW_STYLE, - POPOVER_SURFACE_CLASS, -} from "@/shared/ui/popoverSurface"; + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -type SearchPlacement = "topbar" | "sidebar"; - type TopbarSearchProps = { channels: Channel[]; className?: string; @@ -31,18 +30,6 @@ type TopbarSearchProps = { focusRequest?: number; onOpenChannel: (channelId: string) => void; onOpenResult: (hit: SearchHit) => void; - /** - * Controls how the results popover is anchored. `topbar` centers a wide - * panel under the input; `sidebar` left-anchors a narrower panel that fits - * within the persistent sidebar column. - */ - placement?: SearchPlacement; -}; - -const RESULTS_POPOVER_CLASS: Record = { - topbar: - "left-1/2 w-[620px] max-w-[min(82vw,620px)] -translate-x-1/2 slide-in-from-top-1", - sidebar: "left-0 right-0 w-auto slide-in-from-top-1", }; function describeSearchHit(hit: SearchHit) { @@ -159,12 +146,11 @@ export function TopbarSearch({ focusRequest = 0, onOpenChannel, onOpenResult, - placement = "topbar", }: TopbarSearchProps) { const [isOpen, setIsOpen] = React.useState(false); const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0); - const inputRef = React.useRef(null); - const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + const dialogInputRef = React.useRef(null); const { channelLookup, debouncedQuery, @@ -175,8 +161,6 @@ export function TopbarSearch({ setQuery, } = useSearchResults({ channels, enabled: isOpen, limit: 8 }); const trimmedQuery = query.trim(); - const showSuggestions = isOpen; - const selectableCount = showSuggestions ? results.length : 0; const openResult = React.useCallback( (result: SearchResult) => { @@ -194,198 +178,219 @@ export function TopbarSearch({ ); React.useEffect(() => { - function handlePointerDown(event: PointerEvent) { - if ( - event.target instanceof Node && - rootRef.current?.contains(event.target) - ) { - return; - } - - setIsOpen(false); + if (focusRequest === 0) { + return; } - window.addEventListener("pointerdown", handlePointerDown); - return () => { - window.removeEventListener("pointerdown", handlePointerDown); - }; - }, []); + setIsOpen(true); + triggerRef.current?.focus(); + }, [focusRequest]); React.useEffect(() => { - if (focusRequest === 0) { + if (!isOpen) { return; } - setIsOpen(true); - inputRef.current?.focus(); - inputRef.current?.select(); - }, [focusRequest]); + const animationFrame = window.requestAnimationFrame(() => { + dialogInputRef.current?.focus(); + }); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [isOpen]); React.useEffect(() => { setSelectedMenuIndex((current) => { - if (selectableCount === 0) { + if (results.length === 0) { return 0; } - return Math.min(current, selectableCount - 1); + return Math.min(current, results.length - 1); }); - }, [selectableCount]); + }, [results]); - return ( -
-
- - { - setIsOpen(true); - setQuery(event.target.value); - setSelectedMenuIndex(0); - }} - onFocus={() => setIsOpen(true)} - onKeyDown={(event) => { - if (event.key === "ArrowDown" && selectableCount > 0) { - event.preventDefault(); - setSelectedMenuIndex((current) => - Math.min(current + 1, selectableCount - 1), - ); - return; - } + const handleDialogInputKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" && results.length > 0) { + event.preventDefault(); + setSelectedMenuIndex((current) => + Math.min(current + 1, results.length - 1), + ); + return; + } - if (event.key === "ArrowUp" && selectableCount > 0) { - event.preventDefault(); - setSelectedMenuIndex((current) => Math.max(current - 1, 0)); - return; - } + if (event.key === "ArrowUp" && results.length > 0) { + event.preventDefault(); + setSelectedMenuIndex((current) => Math.max(current - 1, 0)); + return; + } - if (event.key === "Escape") { - event.preventDefault(); - setIsOpen(false); - return; - } + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + const result = results[selectedMenuIndex]; + if (result) { + openResult(result); + } + } + }, + [openResult, results, selectedMenuIndex], + ); - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - const result = results[selectedMenuIndex]; - if (result) { - openResult(result); - } - } - }} - placeholder="Search everything" - spellCheck={false} - value={query} - /> - - ⌘K - + const searchResultContent = + debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? ( +
+

Type at least two characters for live suggestions.

- - {showSuggestions ? ( -
- {debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? ( -
-

Type at least two characters for live suggestions.

-
- ) : searchQuery.isLoading && results.length === 0 ? ( - - ) : searchQuery.error instanceof Error && results.length === 0 ? ( -

- {searchQuery.error.message} -

- ) : results.length === 0 ? ( -

- No matches for{" "} - {trimmedQuery}. -

- ) : ( -
- {results.map((result, index) => ( - - ))} -
- )} -
- ) : null} + + + {result.kind === "channel" + ? result.channel.channelType + : `in #${result.hit.channelName ?? "unknown"}`} + + + + {result.kind === "channel" + ? result.channel.description || "Channel" + : truncateResultText(result.hit.content)} + + + + {result.kind === "channel" + ? "Channel" + : `${describeSearchHit(result.hit)} · ${formatRelativeTime(result.hit.createdAt)}`} + + + ))} +
+ ); + + return ( +
+ + + + + { + event.preventDefault(); + dialogInputRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + triggerRef.current?.focus(); + }} + showCloseButton={false} + > + Search everything +
+ + { + setQuery(event.target.value); + setSelectedMenuIndex(0); + }} + onKeyDown={handleDialogInputKeyDown} + placeholder="Search everything" + spellCheck={false} + value={query} + /> + + ESC + +
+ {searchResultContent} +
+
); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index fba20d3fb..ff136a12e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -530,7 +530,6 @@ export function AppSidebar({ focusRequest={searchFocusRequest} onOpenChannel={onSelectChannel} onOpenResult={onOpenSearchResult} - placement="sidebar" /> { - const bannerElement = document.querySelector( - '[data-testid="welcome-composer-guide-banner"]', - ); - const introElement = document.querySelector( - '[data-testid="message-channel-intro"]', - ); - - if ( - !(bannerElement instanceof HTMLElement) || - !(introElement instanceof HTMLElement) || - bannerElement.dataset.state !== "dismissing" - ) { - return false; - } - - return introElement.getBoundingClientRect().y > beforeY + 4; - }, - { beforeY: introBoxBeforeDismiss.y }, - { polling: "raf", timeout: 5_000 }, - ); + await expect(banner).toHaveAttribute("data-state", "dismissing", { + timeout: 5_000, + }); + await expect(channelIntro).toBeVisible(); await expect(banner).toHaveCount(0, { timeout: 7_000 }); } diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 96f0b3073..c0d60d3c2 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -59,9 +59,8 @@ async function focusSidebarSearchWithShortcut( }), ); }); - await expect(openSearchButton).toBeFocused(); await expect(page.getByTestId("search-results")).toBeVisible(); - await expect(page.getByTestId("search-dialog")).toHaveCount(0); + await expect(page.getByTestId("search-dialog-input")).toBeFocused(); } async function expectHomeView(page: import("@playwright/test").Page) { @@ -220,15 +219,12 @@ test("opens sidebar search with the shortcut and loads the exact result", async await focusSidebarSearchWithShortcut(page); - await page.getByTestId("open-search").fill("shipped"); + await page.getByTestId("search-dialog-input").fill("shipped"); await expect(page.getByTestId("search-results")).toContainText( "Engineering shipped the desktop build.", ); - await page - .getByTestId("search-results") - .getByText("Engineering shipped the desktop build.") - .click(); + await page.keyboard.press("Enter"); await expect(page).toHaveURL( /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9\?messageId=mock-engineering-shipped$/, @@ -244,7 +240,7 @@ test("opens channel matches from search", async ({ page }) => { await focusSidebarSearchWithShortcut(page); - await page.getByTestId("open-search").fill("engineering"); + await page.getByTestId("search-dialog-input").fill("engineering"); const results = page.getByTestId("search-results"); await expect(results).toContainText("engineering"); @@ -259,9 +255,12 @@ test("opens channel matches from search", async ({ page }) => { "search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", ); - await results - .getByTestId("search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9") - .click(); + await expect( + results.getByTestId( + "search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + ), + ).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Enter"); await expect(page).toHaveURL( /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9$/, @@ -269,6 +268,18 @@ test("opens channel matches from search", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("engineering"); }); +test("closes sidebar search with Escape", async ({ page }) => { + await page.goto("/"); + + await focusSidebarSearchWithShortcut(page); + await page.getByTestId("search-dialog-input").fill("shipped"); + + await page.keyboard.press("Escape"); + + await expect(page.getByTestId("search-results")).toHaveCount(0); + await expect(page.getByTestId("open-search")).toBeFocused(); +}); + test("reopens the collapsed sidebar when the search shortcut fires", async ({ page, }) => { @@ -287,9 +298,9 @@ test("reopens the collapsed sidebar when the search shortcut fires", async ({ await focusSidebarSearchWithShortcut(page); - // The shortcut reveals the sidebar and focuses the search input. + // The shortcut reveals the sidebar and focuses the dialog search input. await expect(sidebarRoot).toHaveAttribute("data-state", "expanded"); - await expect(page.getByTestId("open-search")).toBeFocused(); + await expect(page.getByTestId("search-dialog-input")).toBeFocused(); }); test("search results use your resolved profile label instead of You", async ({ @@ -299,7 +310,7 @@ test("search results use your resolved profile label instead of You", async ({ await focusSidebarSearchWithShortcut(page); - await page.getByTestId("open-search").fill("welcome"); + await page.getByTestId("search-dialog-input").fill("welcome"); const results = page.getByTestId("search-results"); await expect(results).toContainText("Welcome to #general"); @@ -314,7 +325,7 @@ test("opens accessible unjoined channels from search in read-only mode", async ( await focusSidebarSearchWithShortcut(page); - await page.getByTestId("open-search").fill("critique"); + await page.getByTestId("search-dialog-input").fill("critique"); const results = page.getByTestId("search-results"); await expect(results).toContainText(