From 5aeb27ee6f3d180fe94e5d70dcb035f90d8a004e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 9 Jul 2026 13:20:39 +0530 Subject: [PATCH] fix: prevent Cmd+K from opening duplicate search dialogs The Search component bundled trigger, dialog, and the Cmd+K keydown listener together. The default theme mounts it twice (mobile header and sidebar header), so each keypress opened two stacked dialogs. Split it into SearchProvider (shared open state), Search (trigger-only button), and SearchDialog (single dialog + single keydown listener). The provider and dialog are mounted once in App, so themes only render trigger buttons. Paper theme's hidden-trigger workaround is removed. Co-Authored-By: Claude Fable 5 --- .../chronicle/src/components/ui/search.tsx | 158 +++++++++++------- packages/chronicle/src/server/App.tsx | 28 ++-- .../src/themes/paper/Layout.module.css | 4 - .../chronicle/src/themes/paper/Layout.tsx | 2 - 4 files changed, 111 insertions(+), 81 deletions(-) diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index 9b953630..95adc402 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -10,7 +10,16 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import GithubSlugger from 'github-slugger'; import { debounce } from 'lodash-es'; import MiniSearch from 'minisearch'; -import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ChangeEvent, + type ReactNode +} from 'react'; import { useNavigate } from 'react-router'; import { MethodBadge } from '@/components/api/method-badge'; import { usePageContext } from '@/lib/page-context'; @@ -162,8 +171,43 @@ function buildSearchUrl(query: string, tag?: string): string { return qs ? `/api/search?${qs}` : '/api/search'; } -export function Search({ classNames }: SearchProps) { +interface SearchContextValue { + open: boolean; + setOpen: React.Dispatch>; +} + +const SearchContext = createContext(null); + +export function SearchProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); + const value = useMemo(() => ({ open, setOpen }), [open]); + return {children}; +} + +function useSearch(): SearchContextValue { + const ctx = useContext(SearchContext); + if (!ctx) throw new Error('Search components must be used within '); + return ctx; +} + +export function Search({ classNames }: SearchProps) { + const { setOpen } = useSearch(); + + return ( + setOpen(true)} + className={classNames?.trigger} + > + + + ); +} + +export function SearchDialog() { + const { open, setOpen } = useSearch(); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const navigate = useNavigate(); @@ -228,67 +272,55 @@ export function Search({ classNames }: SearchProps) { const displayResults = deduplicateByUrl(data); return ( - <> - setOpen(true)} - className={classNames?.trigger} - > - - - - - - - } - value={search} - onChange={onSearchChange} - className={styles.input} - /> - - - {isLoading && displayResults.length === 0 && Loading...} - {!isLoading && - search.length > 0 && - displayResults.length === 0 && ( - No results found. - )} - {search.length === 0 && - displayResults.length > 0 && ( - - Suggestions - {displayResults.slice(0, 8).map((result) => ( - onSelect(result.url)} - className={styles.item} - > - - - ))} - - )} - {search.length > 0 && - displayResults.map((result) => ( - onSelect(result.url)} - className={styles.item} - > - - - ))} - - - - - + + + + } + value={search} + onChange={onSearchChange} + className={styles.input} + /> + + + {isLoading && displayResults.length === 0 && Loading...} + {!isLoading && + search.length > 0 && + displayResults.length === 0 && ( + No results found. + )} + {search.length === 0 && + displayResults.length > 0 && ( + + Suggestions + {displayResults.slice(0, 8).map((result) => ( + onSelect(result.url)} + className={styles.item} + > + + + ))} + + )} + {search.length > 0 && + displayResults.map((result) => ( + onSelect(result.url)} + className={styles.item} + > + + + ))} + + + + ); } diff --git a/packages/chronicle/src/server/App.tsx b/packages/chronicle/src/server/App.tsx index f6dd2ed2..695c8a1c 100644 --- a/packages/chronicle/src/server/App.tsx +++ b/packages/chronicle/src/server/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara'; import { lazy, Suspense } from 'react'; import { Navigate, useLocation } from 'react-router'; import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider'; +import { SearchDialog, SearchProvider } from '@/components/ui/search'; import { usePageContext } from '@/lib/page-context'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import type { ChronicleConfig } from '@/types'; @@ -38,18 +39,21 @@ export function App() { forcedTheme={themeConfig.forcedTheme} > - - }> - {isApi ? ( - - - - ) : ( - - {isLanding ? : } - - )} - + + + {config.search?.enabled && } + }> + {isApi ? ( + + + + ) : ( + + {isLanding ? : } + + )} + + ); diff --git a/packages/chronicle/src/themes/paper/Layout.module.css b/packages/chronicle/src/themes/paper/Layout.module.css index 11348e05..ed59a0ca 100644 --- a/packages/chronicle/src/themes/paper/Layout.module.css +++ b/packages/chronicle/src/themes/paper/Layout.module.css @@ -84,7 +84,3 @@ .contentFull { margin-left: 0; } - -.hiddenTrigger { - display: none; -} diff --git a/packages/chronicle/src/themes/paper/Layout.tsx b/packages/chronicle/src/themes/paper/Layout.tsx index 4e58766c..afedffe9 100644 --- a/packages/chronicle/src/themes/paper/Layout.tsx +++ b/packages/chronicle/src/themes/paper/Layout.tsx @@ -6,7 +6,6 @@ import { useLocation, useNavigate } from 'react-router'; import { getLandingEntries } from '@/lib/config'; import { getActiveContentDir } from '@/lib/navigation'; import { usePageContext } from '@/lib/page-context'; -import { Search } from '@/components/ui/search'; import type { ThemeLayoutProps } from '@/types'; import { ChapterNav } from './ChapterNav'; import styles from './Layout.module.css'; @@ -83,7 +82,6 @@ function LayoutInner({ ) : null}
- {config.search?.enabled && } {children}