From 9d8f2518b66bf70a58a4f30b8b3b48e845d37e92 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Mon, 30 Jun 2025 15:32:54 +0200 Subject: [PATCH 01/35] Initial try-out --- .../gitbook/src/components/Header/Header.tsx | 62 ++++---- .../src/components/Search/SearchButton.tsx | 148 ++++++++---------- .../src/components/Search/SearchContainer.tsx | 136 ++++++++++++++++ .../Search/SearchPageResultItem.tsx | 8 +- .../src/components/Search/SearchResults.tsx | 2 +- .../components/SpaceLayout/SpaceLayout.tsx | 11 +- .../src/components/primitives/Button.tsx | 1 + .../src/components/primitives/Popover.tsx | 22 ++- .../src/components/primitives/styles.ts | 6 +- 9 files changed, 246 insertions(+), 150 deletions(-) create mode 100644 packages/gitbook/src/components/Search/SearchContainer.tsx diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index b10392d1d1..addf5e6550 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -6,7 +6,7 @@ import { getSpaceLanguage, t } from '@/intl/server'; import { tcls } from '@/lib/tailwind'; import { AIChatButton } from '../AIChat/AIChatButton'; -import { SearchButton } from '../Search'; +import { SearchContainer } from '../Search/SearchContainer'; import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections'; import { HeaderLink } from './HeaderLink'; import { HeaderLinkMore } from './HeaderLinkMore'; @@ -123,44 +123,38 @@ export function Header(props: { )} > - 1} + spaceTitle={siteSpace.title} + // style={[ + // 'theme-bold:bg-header-link/2', + // 'theme-bold:hover:bg-header-link/3', - 'theme-bold:text-header-link/8', - 'theme-bold:hover:text-header-link', + // 'theme-bold:text-header-link/8', + // 'theme-bold:hover:text-header-link', - 'theme-bold:ring-header-link/4', - 'theme-bold:hover:ring-header-link/5', + // 'theme-bold:ring-header-link/4', + // 'theme-bold:hover:ring-header-link/5', - 'theme-bold:[&_svg]:text-header-link/10', - 'theme-bold:[&_.shortcut]:text-header-link/8', + // 'theme-bold:[&_svg]:text-header-link/10', + // 'theme-bold:[&_.shortcut]:text-header-link/8', - 'theme-bold:contrast-more:bg-header-background', - 'theme-bold:contrast-more:text-header-link', - 'theme-bold:contrast-more:ring-header-link', - 'theme-bold:contrast-more:hover:bg-header-background', - 'theme-bold:contrast-more:hover:ring-header-link', - 'theme-bold:contrast-more:focus:text-header-link', - 'theme-bold:contrast-more:focus:bg-header-background', - 'theme-bold:contrast-more:focus:ring-header-link', + // 'theme-bold:contrast-more:bg-header-background', + // 'theme-bold:contrast-more:text-header-link', + // 'theme-bold:contrast-more:ring-header-link', + // 'theme-bold:contrast-more:hover:bg-header-background', + // 'theme-bold:contrast-more:hover:ring-header-link', + // 'theme-bold:contrast-more:focus:text-header-link', + // 'theme-bold:contrast-more:focus:bg-header-background', + // 'theme-bold:contrast-more:focus:ring-header-link', - 'theme-bold:shadow-none', - 'theme-bold:hover:shadow-none', - 'whitespace-nowrap', - ]} - > - - {t( - getSpaceLanguage(customization), - customization.aiSearch.enabled - ? 'search_or_ask' - : 'search' - )} - ... - - + // 'theme-bold:shadow-none', + // 'theme-bold:hover:shadow-none', + // 'whitespace-nowrap', + // ]} + /> {withAIChat && } diff --git a/packages/gitbook/src/components/Search/SearchButton.tsx b/packages/gitbook/src/components/Search/SearchButton.tsx index 1c4cd02a68..d343ac1a7f 100644 --- a/packages/gitbook/src/components/Search/SearchButton.tsx +++ b/packages/gitbook/src/components/Search/SearchButton.tsx @@ -1,107 +1,85 @@ 'use client'; import { Icon } from '@gitbook/icons'; -import { useEffect, useState } from 'react'; +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; import { tString, useLanguage } from '@/intl/client'; -import { type ClassValue, tcls } from '@/lib/tailwind'; -import { useTrackEvent } from '../Insights'; -import { useSearch } from './useSearch'; +import { tcls } from '@/lib/tailwind'; +import { useHotkeys } from 'react-hotkeys-hook'; + +interface SearchButtonProps { + onChange: (event: React.ChangeEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + onFocus: () => void; + onBlur: () => void; + value: string; + withAsk?: boolean; +} /** * Button to open the search modal. */ -export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) { - const { style, children } = props; +export function SearchButton(props: SearchButtonProps) { + const { onChange, onKeyDown, onFocus, onBlur, value, withAsk = false } = props; const language = useLanguage(); - const [, setSearchState] = useSearch(); - const trackEvent = useTrackEvent(); - - const onClick = () => { - setSearchState({ - ask: false, - global: false, - query: '', - }); - - trackEvent({ - type: 'search_open', - }); + + const inputRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + + const handleFocus = () => { + onFocus(); + setIsOpen(true); }; + const handleBlur = () => { + onBlur(); + setIsOpen(false); + }; + + useEffect(() => { + if (isOpen === true) { + inputRef.current?.focus(); + } + }, [isOpen]); + + useHotkeys( + 'mod+k', + (e) => { + e.preventDefault(); + handleFocus(); + }, + [] + ); + return ( - + ); } diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx new file mode 100644 index 0000000000..44e1bb2a40 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import React, { useRef } from 'react'; +import { useTrackEvent } from '../Insights'; +import { Popover } from '../primitives'; +import { useSearchAskState } from './SearchAskContext'; +import { SearchAskProvider } from './SearchAskContext'; +import { SearchButton } from './SearchButton'; +import { SearchResults, type SearchResultsRef } from './SearchResults'; +import { useSearch } from './useSearch'; + +interface SearchContainerProps { + spaceTitle: string; + isMultiVariants: boolean; + withAsk: boolean; + withAIChat: boolean; +} + +/** + * Client component to render the search modal when the url contains a search query. + */ +export function SearchContainer(props: SearchContainerProps) { + const { withAsk, withAIChat } = props; + + const [state, setSearchState] = useSearch(); + const searchAsk = useSearchAskState(); + // const [askState] = searchAsk; + const router = useRouter(); + const trackEvent = useTrackEvent(); + const resultsRef = useRef(null); + + const onClose = async (to?: string) => { + await setSearchState(null); + if (to) { + router.push(to); + } + }; + + const onOpen = () => { + setSearchState({ + ask: false, + global: false, + query: state?.query ?? '', + }); + + trackEvent({ + type: 'search_open', + }); + }; + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault(); + resultsRef.current?.moveUp(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + resultsRef.current?.moveDown(); + } else if (event.key === 'Enter') { + event.preventDefault(); + resultsRef.current?.select(); + } + }; + + const onChange = (event: React.ChangeEvent) => { + setSearchState({ + ask: false, // When typing, we go back to the default search mode + query: event.target.value, + global: state?.global ?? false, + }); + }; + + // const onSwitchToAsk = () => { + // setSearchState((state) => (state ? { ...state, ask: true } : null)); + // }; + + // We trim the query to avoid invalidating the search when the user is typing between words. + const normalizedQuery = state?.query.trim() ?? ''; + + return ( + + + {state !== null ? ( + {}} + /> + ) : null} + + } + rootProps={{ + open: state !== null, + }} + contentProps={{ + onOpenAutoFocus: (event) => event.preventDefault(), + align: 'start', + className: + 'bg-tint-base w-[32rem] p-3 h-[32rem] max-w-[min(var(--radix-popover-content-available-width),32rem)]', + onInteractOutside: () => onClose(), + sideOffset: 8, + }} + triggerProps={{ + className: 'grow', + }} + > + + + + ); +} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index aa97e621a4..5c589cd7a9 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -86,7 +86,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt ] : breadcrumbs ).map((crumb, index) => ( - <> +
{index !== 0 ? ( ) : null} - - {crumb} - - + {crumb} +
))} ) : null} diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 5e3c8b8735..0ea65d0081 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -79,7 +79,7 @@ export const SearchResults = React.forwardRef(function SearchResults( let cancelled = false; // Silently fetch the recommended questions, instead of showing a spinner - setResultsState({ results: [], fetching: false }); + setResultsState({ results: [], fetching: true }); // We currently have a bug where the same question can be returned multiple times. // This is a workaround to avoid that. diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 8269519778..d5644eaabf 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; -import { SearchButton, SearchModal } from '@/components/Search'; +import { SearchButton } from '@/components/Search'; import { TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; import { getSpaceLanguage } from '@/intl/server'; @@ -161,15 +161,6 @@ export function SpaceLayout(props: { {withFooter ?