diff --git a/.changeset/purple-toys-grow.md b/.changeset/purple-toys-grow.md new file mode 100644 index 0000000000..84ec2ce4c4 --- /dev/null +++ b/.changeset/purple-toys-grow.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +New search layout diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 8bb007383a..5ddb32bbba 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -6,7 +6,7 @@ import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon'; import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon'; import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; import { getAIChatName } from '@/components/AIChat'; -import AIChatIcon from '@/components/AIChat/AIChatIcon'; +import { AIChatIcon } from '@/components/AIChat'; import { Button } from '@/components/primitives/Button'; import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index e05e5ee7ac..b3d706a748 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -13,7 +13,7 @@ import { import { useNow } from '../hooks'; import { Button } from '../primitives'; import { DropdownMenu, DropdownMenuItem } from '../primitives/DropdownMenu'; -import AIChatIcon from './AIChatIcon'; +import { AIChatIcon } from './AIChatIcon'; import { AIChatInput } from './AIChatInput'; import { AIChatMessages } from './AIChatMessages'; import AIChatSuggestedQuestions from './AIChatSuggestedQuestions'; @@ -108,7 +108,7 @@ export function AIChatWindow(props: { return (
diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index 5135269fcb..f23300a3f3 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -1,9 +1,10 @@ 'use client'; -import { t, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; import { useAIChatController, useAIChatState } from '../AI/useAIChat'; import { Button } from '../primitives'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; -import AIChatIcon from './AIChatIcon'; +import { getAIChatName } from './AIChat'; +import { AIChatIcon } from './AIChatIcon'; /** * Button to open/close the AI chat. @@ -13,20 +14,16 @@ export function AIChatButton(props: { trademark: boolean }) { const chatController = useAIChatController(); const chat = useAIChatState(); - const language = useLanguage(); - return (
{customization.header.links.length > 0 && ( diff --git a/packages/gitbook/src/components/Header/HeaderLink.tsx b/packages/gitbook/src/components/Header/HeaderLink.tsx index 555b37674c..f5461dfcf6 100644 --- a/packages/gitbook/src/components/Header/HeaderLink.tsx +++ b/packages/gitbook/src/components/Header/HeaderLink.tsx @@ -104,7 +104,7 @@ function HeaderItemButton( const variant = (() => { switch (linkStyle) { case 'button-secondary': - return 'secondary'; + return 'header'; case 'button-primary': return 'primary'; default: @@ -120,9 +120,7 @@ function HeaderItemButton( { 'button-primary': 'theme-bold:bg-header-link theme-bold:text-header-background theme-bold:shadow-none theme-bold:hover:bg-header-link theme-bold:hover:text-header-background theme-bold:hover:shadow-none', - 'button-secondary': tcls( - 'theme-bold:bg-header-link/2 theme-gradient:bg-tint-base theme-muted:bg-tint-base theme-bold:text-header-link theme-bold:shadow-none theme-bold:ring-header-link/4 theme-bold:hover:bg-header-link/3 theme-bold:hover:text-header-link theme-bold:hover:shadow-none theme-bold:hover:ring-header-link/5 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:ring-header-link' - ), + 'button-secondary': '', }[linkStyle] )} insights={{ diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 0645ade266..89e4121698 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,5 +1,4 @@ import { - CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, @@ -86,11 +85,6 @@ export async function CustomizationRootLayout(props: { suppressHydrationWarning lang={customization.internationalization.locale} className={tcls( - /* This offset is used when scrolling to elements on the page, like #anchors and (on mobile) the latest AI chat message. - * It is set to the maximum height of the header (64px) + sections (44px) + offset (40px) */ - customization.header.preset === CustomizationHeaderPreset.None - ? null - : 'scroll-pt-[148px]', customization.styling.corners && `${customization.styling.corners}-corners`, 'theme' in customization.styling && `theme-${customization.styling.theme}`, tintColor ? ' tint' : 'no-tint', diff --git a/packages/gitbook/src/components/Search/HighlightQuery.tsx b/packages/gitbook/src/components/Search/HighlightQuery.tsx index f0b32d74b0..2a830a547c 100644 --- a/packages/gitbook/src/components/Search/HighlightQuery.tsx +++ b/packages/gitbook/src/components/Search/HighlightQuery.tsx @@ -23,6 +23,9 @@ export function HighlightQuery(props: { 'py-0.5', 'rounded', 'straight-corners:rounded-sm', + 'transition-colors', + 'group-hover:bg-primary-active', + 'group-hover:text-contrast-primary-active', 'group-[.is-active]:bg-primary-active', 'group-[.is-active]:text-contrast-primary-active', ], diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index f7c4b1fc8e..72c95ed34e 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -88,20 +88,22 @@ export function SearchAskAnswer(props: { query: string }) { }, [setAskState]); const loading = ( -
- +
+
); return ( -
+
{askState?.type === 'answer' ? ( ) : null} {askState?.type === 'error' ? ( -
{t(language, 'search_ask_error')}
+
+ {t(language, 'search_ask_error')} +
) : null} {askState?.type === 'loading' ? loading : null}
@@ -138,10 +140,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) { return ( <> -
+
{answer.body ?? t(language, 'search_ask_no_answer')} {answer.followupQuestions.length > 0 ? ( @@ -212,8 +211,7 @@ function AnswerSources(props: { 'gap-2', 'mt-4', 'sm:mt-6', - 'py-4', - 'px-4', + 'pt-4', 'border-t', 'border-subtle' )} diff --git a/packages/gitbook/src/components/Search/SearchButton.tsx b/packages/gitbook/src/components/Search/SearchButton.tsx deleted file mode 100644 index 9b1734f4c6..0000000000 --- a/packages/gitbook/src/components/Search/SearchButton.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import { Icon } from '@gitbook/icons'; - -import { tString, useLanguage } from '@/intl/client'; -import { type ClassValue, tcls } from '@/lib/tailwind'; -import { useTrackEvent } from '../Insights'; -import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; -import { useSearch } from './useSearch'; - -/** - * Button to open the search modal. - */ -export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) { - const { style, children } = props; - - const language = useLanguage(); - const [, setSearchState] = useSearch(); - const trackEvent = useTrackEvent(); - - const onClick = () => { - setSearchState({ - ask: false, - global: false, - query: '', - }); - - trackEvent({ - type: 'search_open', - }); - }; - - 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..ccb5109b3e --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { CustomizationAIMode } from '@gitbook/api'; +import { useRouter } from 'next/navigation'; +import React, { useRef } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useTrackEvent } from '../Insights'; +import { useIsMobile } from '../hooks/useIsMobile'; +import { Popover } from '../primitives'; +import { SearchAskAnswer } from './SearchAskAnswer'; +import { useSearchAskState } from './SearchAskContext'; +import { SearchAskProvider } from './SearchAskContext'; +import { SearchInput } from './SearchInput'; +import { SearchResults, type SearchResultsRef } from './SearchResults'; +import { SearchScopeToggle } from './SearchScopeToggle'; +import { useSearch } from './useSearch'; + +interface SearchContainerProps { + spaceTitle: string; + isMultiVariants: boolean; + aiMode: CustomizationAIMode; + className?: string; +} + +/** + * Client component to render the search input and results. + */ +export function SearchContainer(props: SearchContainerProps) { + const { spaceTitle, isMultiVariants, aiMode, className } = props; + const withAI = + aiMode === CustomizationAIMode.Search || aiMode === CustomizationAIMode.Assistant; + + const [state, setSearchState] = useSearch(); + const searchAsk = useSearchAskState(); + const router = useRouter(); + const trackEvent = useTrackEvent(); + const resultsRef = useRef(null); + const searchInputRef = useRef(null); + + const isMobile = useIsMobile(); + + // Derive open state from search state + const open = state?.open ?? false; + + const onClose = async (to?: string) => { + if (state?.query === '') { + await setSearchState(null); + } else if (state) { + await setSearchState({ ...state, open: false }); + } + + if (to) { + router.push(to); + } + }; + + useHotkeys( + 'mod+k', + (e) => { + e.preventDefault(); + onOpen(); + }, + [] + ); + + const onOpen = () => { + if (open) { + return; + } + setSearchState((prev) => ({ + ask: prev?.ask ?? false, + global: prev?.global ?? false, + query: prev?.query ?? '', + open: true, + })); + + 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 = (value: string) => { + setSearchState((prev) => ({ + ask: false, // When typing, we go back to the default search mode + query: value, + global: prev?.global ?? false, + open: true, + })); + }; + + // We trim the query to avoid invalidating the search when the user is typing between words. + const normalizedQuery = state?.query.trim() ?? ''; + + return ( + + + {isMultiVariants && !state?.ask ? ( + + ) : null} + {state !== null && !state.ask ? ( + + ) : null} + {state?.ask ? : null} + + ) : null + } + rootProps={{ + open: open, + modal: isMobile, + }} + contentProps={{ + onOpenAutoFocus: (event) => event.preventDefault(), + align: 'start', + className: + 'bg-tint-base has-[.empty]:hidden scroll-py-2 w-[32rem] p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]', + onInteractOutside: (event) => { + // Don't close if clicking on the search input itself + if (searchInputRef.current?.contains(event.target as Node)) { + return; + } + onClose(); + }, + sideOffset: 8, + collisionPadding: { + top: 16, + right: 16, + bottom: 32, + left: 16, + }, + hideWhenDetached: true, + }} + triggerProps={{ + asChild: true, + }} + > + + + + ); +} diff --git a/packages/gitbook/src/components/Search/SearchInput.tsx b/packages/gitbook/src/components/Search/SearchInput.tsx new file mode 100644 index 0000000000..4d2b6c52c0 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchInput.tsx @@ -0,0 +1,143 @@ +'use client'; +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { tString, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import { Button, variantClasses } from '../primitives'; +import { useClassnames } from '../primitives/StyleProvider'; + +interface SearchInputProps { + onChange: (value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + onFocus: () => void; + value: string; + withAI?: boolean; + isOpen: boolean; + className?: string; +} + +// Size classes for medium size button +const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'circular-corners:px-4']; + +/** + * Input to trigger search. + */ +export const SearchInput = React.forwardRef( + function SearchInput(props, ref) { + const { onChange, onKeyDown, onFocus, value, withAI = false, isOpen, className } = props; + const inputRef = useRef(null); + + const language = useLanguage(); + const buttonStyles = useClassnames(['ButtonStyles']); + + useEffect(() => { + if (isOpen) { + if (document.activeElement !== inputRef.current) { + // Refocus the input and move the caret to the end – do this only once to avoid scroll jumps on every keystroke + inputRef.current?.focus({ preventScroll: true }); + // Place cursor at the end of the input + inputRef.current?.setSelectionRange(value.length, value.length); + } + } else { + inputRef.current?.blur(); + } + }, [isOpen, value]); + + return ( +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: this div needs an onClick to show the input on mobile, where it's normally hidden. + Normally you'd also need to add a keyboard trigger to do the same without a pointer, but in this case the input already be focused on its own. */} +
+ {value && isOpen ? ( +
+
+ ); + } +); + +function getOperatingSystem() { + const platform = navigator.platform.toLowerCase(); + + if (platform.includes('mac')) return 'mac'; + if (platform.includes('win')) return 'win'; + + return 'win'; +} + +function Shortcut() { + const [operatingSystem, setOperatingSystem] = useState(null); + + useEffect(() => { + setOperatingSystem(getOperatingSystem()); + }, []); + + return ( + + ); +} diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx deleted file mode 100644 index 776f331dcd..0000000000 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ /dev/null @@ -1,309 +0,0 @@ -'use client'; - -import { Icon } from '@gitbook/icons'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useRouter } from 'next/navigation'; -import React from 'react'; -import { useHotkeys } from 'react-hotkeys-hook'; - -import { tString, useLanguage } from '@/intl/client'; -import { tcls } from '@/lib/tailwind'; - -import { LoadingPane } from '../primitives/LoadingPane'; -import { SearchAskAnswer } from './SearchAskAnswer'; -import { SearchAskProvider, useSearchAskState } from './SearchAskContext'; -import { SearchResults, type SearchResultsRef } from './SearchResults'; -import { SearchScopeToggle } from './SearchScopeToggle'; -import { type SearchState, type UpdateSearchState, useSearch } from './useSearch'; - -interface SearchModalProps { - spaceTitle: string; - isMultiVariants: boolean; - withAsk: boolean; - withAIChat: boolean; -} - -/** - * Client component to render the search modal when the url contains a search query. - */ -export function SearchModal(props: SearchModalProps) { - const [state, setSearchState] = useSearch(); - const searchAsk = useSearchAskState(); - const [askState] = searchAsk; - const router = useRouter(); - - useHotkeys( - 'mod+k', - (e) => { - e.preventDefault(); - setSearchState({ ask: false, query: '', global: false }); - }, - [] - ); - - // Add a global class on the body when the search modal is open - const isSearchOpened = state !== null; - React.useEffect(() => { - if (isSearchOpened) { - document.body.style.overflow = 'hidden'; - } - - return () => { - document.body.style.overflow = 'auto'; - }; - }, [isSearchOpened]); - - const onClose = async (to?: string) => { - await setSearchState(null); - if (to) { - router.push(to); - } - }; - - return ( - - - {state !== null ? ( - { - onClose(); - }} - > -
- - {askState?.type === 'loading' ? ( - - - - ) : null} - - -
-
- ) : null} -
-
- ); -} - -function SearchModalBody( - props: SearchModalProps & { - state: SearchState; - setSearchState: UpdateSearchState; - onClose: (to?: string) => void; - } -) { - const { spaceTitle, withAsk, withAIChat, isMultiVariants, state, setSearchState, onClose } = - props; - - const language = useLanguage(); - const resultsRef = React.useRef(null); - const inputRef = React.useRef(null); - - React.useEffect(() => { - inputRef.current?.focus(); - }, []); - - 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, - }); - }; - - 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 ( - { - event.stopPropagation(); - }} - > -
-
- -
-
- - {isMultiVariants ? : null} -
-
- {!state.ask || !withAsk ? ( - - ) : null} - {normalizedQuery && state.ask && withAsk ? ( - - ) : null} -
- ); -} diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index aa97e621a4..47bb51de0f 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -1,9 +1,9 @@ +import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; - -import { Link } from '../primitives'; import { HighlightQuery } from './HighlightQuery'; +import { SearchResultItem } from './SearchResultItem'; import type { ComputedPageResult } from './server-actions'; export const SearchPageResultItem = React.forwardRef(function SearchPageResultItem( @@ -15,6 +15,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt ref: React.Ref ) { const { query, item, active } = props; + const language = useLanguage(); const breadcrumbs = item.breadcrumbs?.map((crumb) => ( @@ -25,26 +26,12 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt )) ?? []; return ( - } insights={{ type: 'search_open_result', query, @@ -54,69 +41,50 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt }, }} > -
- -
-
- {breadcrumbs.length > 0 ? ( -
- {(breadcrumbs.length > 3 - ? [ - ...breadcrumbs.slice(0, 2), - , - ...breadcrumbs.slice(-1), - ] - : breadcrumbs - ).map((crumb, index) => ( - <> - {index !== 0 ? ( - - ) : null} - - {crumb} - - - ))} -
- ) : null} + {breadcrumbs.length > 0 ? ( +
+ {(breadcrumbs.length > 3 + ? [ + ...breadcrumbs.slice(0, 2), + , + ...breadcrumbs.slice(-1), + ] + : breadcrumbs + ).map((crumb, index) => ( + + {index !== 0 ? ( + + ) : null} + {crumb} + + ))} +
+ ) : null} +

-

-
- -
- +

+ ); }); diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 4554da5ab6..678de3407a 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -1,101 +1,73 @@ -import { Icon } from '@gitbook/icons'; import React from 'react'; -import { t, useLanguage } from '@/intl/client'; -import { tcls } from '@/lib/tailwind'; - +import { t, tString, useLanguage } from '@/intl/client'; +import { Icon } from '@gitbook/icons'; import { useAIChatController } from '../AI'; -import { Link } from '../primitives'; +import { AIChatIcon } from '../AIChat/'; +import { SearchResultItem } from './SearchResultItem'; import { useSearch, useSearchLink } from './useSearch'; export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem( props: { question: string; active: boolean; - onClick: () => void; recommended?: boolean; withAIChat: boolean; }, ref: React.Ref ) { - const { question, recommended = false, active, onClick, withAIChat } = props; + const { question, recommended = false, active, withAIChat } = props; const language = useLanguage(); const getLinkProp = useSearchLink(); const chatController = useAIChatController(); const [, setSearchState] = useSearch(); return ( - { if (withAIChat) { // If AI Chat is enabled, hijack to open the chat and post the question chatController.open(); chatController.postMessage({ message: question }); setSearchState(null); // Close the search modal - } else { - onClick(); } }} - data-testid="search-result-item" - className={tcls( - 'flex', - 'px-4', - recommended ? ['py-2', 'text-tint'] : 'py-4', - 'hover:bg-tint-hover', - 'first:mt-0', - 'last:pb-3', - active && [ - 'is-active', - 'bg-primary', - 'text-contrast-primary', - 'hover:bg-primary-hover', - ] - )} - {...(withAIChat - ? { href: '#' } - : getLinkProp({ - ask: true, - query: question, - }))} - > - -
- {recommended ? ( - question + active={active} + leadingIcon={ + recommended ? ( + + ) : withAIChat ? ( + ) : ( - <> -
{t(language, 'search_ask', [question])}
-
- {t(language, 'search_ask_description')} -
- - )} -
-
- -
- + + ) + } + className={recommended ? 'pr-1.5' : ''} + > + {recommended ? ( + question + ) : ( + <> +
+ {t(language, 'search_ask', [question])} +
+
+ {t(language, 'search_ask_description')} +
+ + )} + ); }); diff --git a/packages/gitbook/src/components/Search/SearchResultItem.tsx b/packages/gitbook/src/components/Search/SearchResultItem.tsx new file mode 100644 index 0000000000..949e384cb3 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchResultItem.tsx @@ -0,0 +1,73 @@ +import { tcls } from '@/lib/tailwind'; +import { Icon } from '@gitbook/icons'; +import * as React from 'react'; +import { Button } from '../primitives'; +import { Link, type LinkInsightsProps } from '../primitives/Link'; + +export const SearchResultItem = React.forwardRef(function SearchResultItem( + props: { + children: React.ReactNode; + href: string; + action: string; + active: boolean; + className?: string; + size?: 'small' | 'medium'; + leadingIcon?: React.ReactNode; + } & LinkInsightsProps & + Omit, keyof LinkInsightsProps>, + ref: React.Ref +) { + const { + children, + href, + active, + className, + leadingIcon, + size = 'medium', + action, + ...rest + } = props; + + return ( + +
{leadingIcon}
+
{children}
+ {active ? ( +
); } - -function ToggleButton(props: { onClick: () => void; children: React.ReactNode; active: boolean }) { - const { onClick, children, active } = props; - return ( - - ); -} diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index 8ccda684d2..f324671fa9 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -1,10 +1,8 @@ -import { Icon } from '@gitbook/icons'; import React from 'react'; -import { tcls } from '@/lib/tailwind'; - -import { Link } from '../primitives'; +import { tString, useLanguage } from '@/intl/client'; import { HighlightQuery } from './HighlightQuery'; +import { SearchResultItem } from './SearchResultItem'; import type { ComputedSectionResult } from './server-actions'; export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem( @@ -16,30 +14,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe ref: React.Ref ) { const { query, item, active } = props; + const language = useLanguage(); return ( - -
+
{item.title ? ( -

+

) : null} {item.body ? highlightQueryInBody(item.body, query) : null}
-
- -
- + ); }); @@ -92,7 +49,7 @@ function highlightQueryInBody(body: string, query: string) { // Ensure the query to be highlighted is visible in the body. return ( -

+

); diff --git a/packages/gitbook/src/components/Search/index.ts b/packages/gitbook/src/components/Search/index.ts index a316dad375..73977f05ae 100644 --- a/packages/gitbook/src/components/Search/index.ts +++ b/packages/gitbook/src/components/Search/index.ts @@ -1,2 +1,2 @@ -export * from './SearchButton'; -export * from './SearchModal'; +export * from './SearchInput'; +export * from './SearchContainer'; diff --git a/packages/gitbook/src/components/Search/useSearch.ts b/packages/gitbook/src/components/Search/useSearch.ts index 8f3ecfaec5..70bb1a34fb 100644 --- a/packages/gitbook/src/components/Search/useSearch.ts +++ b/packages/gitbook/src/components/Search/useSearch.ts @@ -4,9 +4,13 @@ import React from 'react'; import type { LinkProps } from '../primitives'; export interface SearchState { + // URL-backed state query: string; ask: boolean; global: boolean; + + // Local UI state + open: boolean; } // KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render. @@ -28,13 +32,24 @@ export function useSearch(): [SearchState | null, UpdateSearchState] { history: 'replace', }); + // Separate local state for open (not synchronized with URL) + // Default to true if there's already a query in the URL + const [open, setIsOpen] = React.useState(() => { + return rawState?.q !== null; + }); + const state = React.useMemo(() => { if (rawState === null || rawState.q === null) { return null; } - return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global }; - }, [rawState]); + return { + query: rawState.q, + ask: !!rawState.ask, + global: !!rawState.global, + open: open, + }; + }, [rawState, open]); const stateRef = React.useRef(state); React.useLayoutEffect(() => { @@ -50,12 +65,17 @@ export function useSearch(): [SearchState | null, UpdateSearchState] { } if (update === null) { + setIsOpen(false); return setRawState({ q: null, ask: null, global: null, }); } + + // Update the local state + setIsOpen(update.open); + return setRawState({ q: update.query, ask: update.ask ? true : null, @@ -78,8 +98,8 @@ export function useSearchLink(): (query: Partial) => LinkProps { (query) => { const searchParams = new URLSearchParams(); searchParams.set('q', query.query ?? ''); - query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask'); - query.global ? searchParams.set('global', 'on') : searchParams.delete('global'); + query.ask ? searchParams.set('ask', 'true') : searchParams.delete('ask'); + query.global ? searchParams.set('global', 'true') : searchParams.delete('global'); return { href: `?${searchParams.toString()}`, prefetch: false, @@ -89,6 +109,7 @@ export function useSearchLink(): (query: Partial) => LinkProps { query: '', ask: false, global: false, + open: true, ...(prev ?? {}), ...query, })); diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 5f56bf2db1..e667aa187b 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -95,6 +95,9 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P ? 'dark light' : 'light dark' : customization.themes.default, + width: 'device-width', + initialScale: 1, + maximumScale: 1, }; } diff --git a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx index b078fd94d2..d4eb32653f 100644 --- a/packages/gitbook/src/components/SitePage/PageClientLayout.tsx +++ b/packages/gitbook/src/components/SitePage/PageClientLayout.tsx @@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks'; export function PageClientLayout(props: { withSections?: boolean }) { // We use this hook in the page layout to ensure the elements for the blocks // are rendered before we scroll to a hash or to the top of the page - useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined }); + useScrollPage({ scrollMarginTop: props.withSections ? 108 : 64 }); useStripFallbackQueryParam(); return null; diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 4f7e76a37a..0c0efdb1ed 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -4,20 +4,18 @@ import React from 'react'; import { Footer } from '@/components/Footer'; import { Header, HeaderLogo } from '@/components/Header'; -import { SearchButton, SearchModal } from '@/components/Search'; import { TableOfContents } from '@/components/TableOfContents'; import { CONTAINER_STYLE } from '@/components/layout'; -import { getSpaceLanguage } from '@/intl/server'; -import { t } from '@/intl/translate'; import { tcls } from '@/lib/tailwind'; -import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import type { VisitorAuthClaims } from '@/lib/adaptive'; import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@/lib/env'; import { AIChat } from '../AIChat'; +import { AIChatButton } from '../AIChat'; import { Announcement } from '../Announcement'; import { SpacesDropdown } from '../Header/SpacesDropdown'; import { InsightsProvider } from '../Insights'; +import { SearchContainer } from '../Search'; import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { CurrentContentProvider } from '../hooks'; import { SpaceLayoutContextProvider } from './SpaceLayoutContext'; @@ -51,7 +49,22 @@ export function SpaceLayout(props: { customization.footer.logo || customization.footer.groups?.length; - const withAIChat = isAIChatEnabled(context); + const aiMode = customization.ai?.mode; + + const searchAndAI = ( +
+ + 1} + spaceTitle={siteSpace.title} + /> + + {aiMode === CustomizationAIMode.Assistant ? ( + + ) : null} +
+ ); return ( @@ -69,16 +82,12 @@ export function SpaceLayout(props: { enabled={withTracking} appURL={GITBOOK_APP_URL} apiHost={GITBOOK_API_PUBLIC_URL} - visitorCookieTrackingEnabled={context.customization.insights?.trackingCookie} + visitorCookieTrackingEnabled={customization.insights?.trackingCookie} > -
- {withAIChat ? ( - +
+ {aiMode === CustomizationAIMode.Assistant ? ( + ) : null}
@@ -103,7 +112,7 @@ export function SpaceLayout(props: { className={tcls( 'hidden', 'pr-4', - 'lg:flex', + 'md:flex', 'grow-0', 'flex-wrap', 'dark:shadow-light/1', @@ -117,27 +126,7 @@ export function SpaceLayout(props: { innerHeader={ // displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC. <> - {!withTopHeader && ( -
- - - - {t( - getSpaceLanguage(customization), - // TODO: remove aiSearch and optional chain once the cache has been fully updated (after 11/07/2025) - customization.aiSearch - ?.enabled || - customization.ai?.mode !== - CustomizationAIMode.None - ? 'search_or_ask' - : 'search' - )} - ... - - - -
- )} + {!withTopHeader && searchAndAI} {!withTopHeader && withSections && sections && ( {withFooter ?