diff --git a/.changeset/wicked-hornets-crash.md b/.changeset/wicked-hornets-crash.md new file mode 100644 index 0000000000..8ae9d0b826 --- /dev/null +++ b/.changeset/wicked-hornets-crash.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Scope search across sections and variants diff --git a/packages/gitbook/src/components/AI/useAI.tsx b/packages/gitbook/src/components/AI/useAI.tsx index 6cb6744ba1..e3dbecc07f 100644 --- a/packages/gitbook/src/components/AI/useAI.tsx +++ b/packages/gitbook/src/components/AI/useAI.tsx @@ -137,7 +137,7 @@ export function useAI(): AIContext { setSearchState((prev) => ({ ask: null, // Reset ask as we assume the assistant will handle it query: prev?.query ?? null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: false, })); assistant.open(query); diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 992209635a..55317f8d76 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -146,7 +146,7 @@ export function AIChatProvider(props: { setSearchState((prev) => ({ ask: prev?.ask ?? initialQuery ?? '', query: prev?.query ?? null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: false, // Close search popover when opening chat })); }, [setSearchState]); @@ -159,7 +159,7 @@ export function AIChatProvider(props: { setSearchState((prev) => ({ ask: null, query: prev?.query ?? null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: false, })); }, [setSearchState]); @@ -374,7 +374,7 @@ export function AIChatProvider(props: { setSearchState((prev) => ({ ask: input.message, query: prev?.query ?? null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: false, })); } @@ -435,7 +435,7 @@ export function AIChatProvider(props: { setSearchState((prev) => ({ ask: '', query: prev?.query ?? null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: false, })); }, [setSearchState]); diff --git a/packages/gitbook/src/components/Header/Header.tsx b/packages/gitbook/src/components/Header/Header.tsx index e3be0abba8..9f357f63bb 100644 --- a/packages/gitbook/src/components/Header/Header.tsx +++ b/packages/gitbook/src/components/Header/Header.tsx @@ -120,9 +120,28 @@ export function Header(props: { > 1} + withVariants={withVariants === 'generic'} + withSiteVariants={ + sections?.list.some( + (s) => + s.object === 'site-section' && + s.siteSpaces.filter( + (s) => s.space.language === siteSpace.space.language + ).length > 1 + ) ?? false + } + withSections={!!sections} + section={ + sections + ? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear + encodeClientSiteSections(context, sections).current + : undefined + } spaceTitle={siteSpace.title} siteSpaceId={siteSpace.id} + siteSpaceIds={siteSpaces + .filter((s) => s.space.language === siteSpace.space.language) + .map((s) => s.id)} viewport={!withTopHeader ? 'mobile' : undefined} /> diff --git a/packages/gitbook/src/components/Search/SearchContainer.tsx b/packages/gitbook/src/components/Search/SearchContainer.tsx index dc70365445..a004d741d5 100644 --- a/packages/gitbook/src/components/Search/SearchContainer.tsx +++ b/packages/gitbook/src/components/Search/SearchContainer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CustomizationSearchStyle } from '@gitbook/api'; +import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api'; import { useRouter } from 'next/navigation'; import React, { useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle'; import { useSearch } from './useSearch'; interface SearchContainerProps { + /** The current site space id. */ siteSpaceId: string; + + /** The title of the current space. */ spaceTitle: string; - isMultiVariants: boolean; + + /** The ids of all spaces in the current section. */ + siteSpaceIds: string[]; + + /** Whether there are sections on the site. */ + withSections: boolean; + + /** The current section, displayed in search scope toggle. */ + section?: Pick; + + /** Whether the current section has variants. */ + withVariants: boolean; + + /** Whether any section on the site has variants. */ + withSiteVariants: boolean; + style: CustomizationSearchStyle; className?: string; viewport?: 'desktop' | 'mobile'; @@ -30,7 +48,18 @@ interface SearchContainerProps { * Client component to render the search input and results. */ export function SearchContainer(props: SearchContainerProps) { - const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props; + const { + siteSpaceId, + spaceTitle, + section, + withVariants, + withSiteVariants, + withSections, + style, + className, + viewport, + siteSpaceIds, + } = props; const { assistants } = useAI(); @@ -108,7 +137,7 @@ export function SearchContainer(props: SearchContainerProps) { } setSearchState((prev) => ({ ask: withAI ? (prev?.ask ?? null) : null, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '', open: true, })); @@ -148,7 +177,7 @@ export function SearchContainer(props: SearchContainerProps) { setSearchState((prev) => ({ ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined) query: value, - global: prev?.global ?? false, + scope: prev?.scope ?? 'default', open: true, })); }; @@ -168,15 +197,22 @@ export function SearchContainer(props: SearchContainerProps) { // Only show content if there's a query or Ask is enabled state?.query || withAI ? ( - {isMultiVariants && !showAsk ? ( - + {(withVariants || withSections) && !showAsk ? ( + ) : null} {state !== null && !showAsk ? ( ) : null} {showAsk ? : null} @@ -194,7 +230,7 @@ export function SearchContainer(props: SearchContainerProps) { onOpenAutoFocus: (event) => event.preventDefault(), align: 'start', className: - 'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]', + '@container bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 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)) { diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 687f31f383..7ce11add2e 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem'; import { type OrderedComputedResult, searchAllSiteContent, - searchSiteSpaceContent, + searchCurrentSiteSpaceContent, + searchSpecificSiteSpaceContent, streamRecommendedQuestions, } from './server-actions'; +import type { SearchScope } from './useSearch'; export interface SearchResultsRef { moveUp(): void; @@ -50,12 +52,13 @@ export const SearchResults = React.forwardRef(function SearchResults( props: { children?: React.ReactNode; query: string; - global: boolean; + scope: SearchScope; siteSpaceId: string; + siteSpaceIds: string[]; }, ref: React.Ref ) { - const { children, query, global, siteSpaceId } = props; + const { children, query, scope, siteSpaceId, siteSpaceIds } = props; const language = useLanguage(); const trackEvent = useTrackEvent(); @@ -133,9 +136,25 @@ export const SearchResults = React.forwardRef(function SearchResults( setResultsState((prev) => ({ results: prev.results, fetching: true })); let cancelled = false; const timeout = setTimeout(async () => { - const results = await (global - ? searchAllSiteContent(query) - : searchSiteSpaceContent(query)); + const results = await (() => { + if (scope === 'all') { + // Search all content on the site + return searchAllSiteContent(query); + } + if (scope === 'default') { + // Search the current section's variant + matched/default variant for other sections + return searchCurrentSiteSpaceContent(query, siteSpaceId); + } + if (scope === 'extended') { + // Search all variants of the current section + return searchSpecificSiteSpaceContent(query, siteSpaceIds); + } + if (scope === 'current') { + // Search only the current section's current variant + return searchSpecificSiteSpaceContent(query, [siteSpaceId]); + } + throw new Error(`Unhandled search scope: ${scope}`); + })(); if (cancelled) { return; @@ -158,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults( cancelled = true; clearTimeout(timeout); }; - }, [query, global, trackEvent, withAI, siteSpaceId]); + }, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds]); const results: ResultType[] = React.useMemo(() => { if (!withAI) { diff --git a/packages/gitbook/src/components/Search/SearchScopeToggle.tsx b/packages/gitbook/src/components/Search/SearchScopeToggle.tsx index 739bce7e0b..56bfce368b 100644 --- a/packages/gitbook/src/components/Search/SearchScopeToggle.tsx +++ b/packages/gitbook/src/components/Search/SearchScopeToggle.tsx @@ -1,13 +1,22 @@ +'use client'; + import { tString, useLanguage } from '@/intl/client'; -import { Button } from '../primitives'; +import type { SiteSection } from '@gitbook/api'; +import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl'; import { useSearch } from './useSearch'; /** * Toolbar to toggle between search modes (global or scoped to a space). * Only visible when the space is in a collection. */ -export function SearchScopeToggle(props: { spaceTitle: string }) { - const { spaceTitle } = props; +export function SearchScopeToggle(props: { + spaceTitle: string; + section?: Pick; + withVariants: boolean; + withSiteVariants: boolean; + withSections: boolean; +}) { + const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props; const [state, setSearchState] = useSearch(); const language = useLanguage(); @@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) { } return ( -
-
+ <> + {withSections ? ( + + {/* `Default` scope = current section's current variant + best match in other sections */} + setSearchState({ ...state, scope: 'default' })} + /> + + {/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */} + setSearchState({ ...state, scope: 'current' })} + /> + + {/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */} + {withSiteVariants ? ( + setSearchState({ ...state, scope: 'all' })} + /> + ) : null} + + ) : null} + {withVariants && + (!withSections || state.scope === 'current' || state.scope === 'extended') ? ( + + {/* `Current` scope = current section's current variant. `Default` on sites without sections. */} + + setSearchState({ + ...state, + scope: withSections ? 'current' : 'default', + }) + } + /> + + {/* `Extended` scope = all variants of the current section. `All` on sites without sections. */} + + setSearchState({ ...state, scope: withSections ? 'extended' : 'all' }) + } + /> + + ) : null} + ); } diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index 89e57a39f4..2f366efab6 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -81,18 +81,33 @@ export async function searchAllSiteContent(query: string): Promise { +export async function searchCurrentSiteSpaceContent( + query: string, + siteSpaceId: string +): Promise { + return traceErrorOnly('Search.searchSiteSpaceContent', async () => { + const context = await getServerActionBaseContext(); + + return await searchSiteContent(context, { + query, + scope: { mode: 'current', siteSpaceId }, + }); + }); +} + +/** + * Server action to search content in a specific space. + */ +export async function searchSpecificSiteSpaceContent( + query: string, + siteSpaceIds: string[] +): Promise { return traceErrorOnly('Search.searchSiteSpaceContent', async () => { const context = await getServerActionBaseContext(); - const siteURLData = await getSiteURLDataFromMiddleware(); return await searchSiteContent(context, { query, - // If we have a siteSectionId that means its a sections site use `current` mode - // which searches in the current space + all default spaces of sections - scope: siteURLData.siteSection - ? { mode: 'current', siteSpaceId: siteURLData.siteSpace } - : { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] }, + scope: { mode: 'specific', siteSpaceIds }, }); }); } diff --git a/packages/gitbook/src/components/Search/useSearch.tsx b/packages/gitbook/src/components/Search/useSearch.tsx index d1c55ad7e3..78fe6442e9 100644 --- a/packages/gitbook/src/components/Search/useSearch.tsx +++ b/packages/gitbook/src/components/Search/useSearch.tsx @@ -1,14 +1,24 @@ 'use client'; -import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'; +import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'; import React from 'react'; import type { LinkProps } from '../primitives'; +export type SearchScope = + /** Search all content on the site */ + | 'all' + /** Search the current section's variant + matched/default variant for other sections */ + | 'default' + /** Search all variants of the current section */ + | 'extended' + /** Search only the current section's current variant */ + | 'current'; + export interface SearchState { // URL-backed state query: string | null; ask: string | null; - global: boolean; + scope: SearchScope; // Local UI state open: boolean; @@ -18,7 +28,8 @@ export interface SearchState { const keyMap = { q: parseAsString, ask: parseAsString, - global: parseAsBoolean, + scope: parseAsStringLiteral(['all', 'default', 'extended', 'current']).withDefault('default'), + global: parseAsBoolean, // Legacy support for global=true }; export type UpdateSearchState = ( @@ -41,14 +52,21 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea history: 'replace', }); - // Handle legacy ask=true format by converting it to the new format React.useEffect(() => { + // Handle legacy ask=true format by converting it to the new format if (rawState?.ask === 'true' && rawState?.q) { // Convert legacy format: q=query&ask=true -> ask=query&q=null setRawState({ q: null, ask: rawState.q, - global: rawState.global, + }); + } + + // Handle legacy global=true + if (rawState?.global === true) { + setRawState({ + scope: 'all', + global: null, // Remove the legacy parameter }); } }, [rawState, setRawState]); @@ -65,7 +83,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea return { query: rawState.q, ask: rawState.ask, - global: !!rawState.global, + scope: rawState.scope, open, }; }, [rawState, open]); @@ -85,14 +103,14 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea if (update === null) { setIsOpen(false); - return setRawState({ q: null, ask: null, global: null }); + return setRawState({ q: null, ask: null, scope: 'default' }); } setIsOpen(update.open); return setRawState({ q: update.query, ask: update.ask, - global: update.global ? true : null, + scope: update.scope, }); }, [setRawState] @@ -126,7 +144,7 @@ export function useSearchLink(): ( const searchParams = new URLSearchParams(); params.query ? searchParams.set('q', params.query) : searchParams.delete('q'); params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask'); - params.global ? searchParams.set('global', 'true') : searchParams.delete('global'); + params.scope ? searchParams.set('scope', params.scope) : searchParams.delete('scope'); return { href: `?${searchParams.toString()}`, prefetch: false, @@ -137,7 +155,7 @@ export function useSearchLink(): ( ...prev, query: params.query !== undefined ? params.query : null, ask: params.ask !== undefined ? params.ask : null, - global: params.global !== undefined ? params.global : false, + scope: params.scope !== undefined ? params.scope : 'default', open: params.open !== undefined ? params.open : false, })); }, diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 4630d5d036..fe25d5cef5 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -180,9 +180,29 @@ export function SpaceLayout(props: SpaceLayoutProps) {
1} + withVariants={withVariants === 'generic'} + withSiteVariants={ + sections?.list.some( + (s) => + s.object === 'site-section' && + s.siteSpaces.filter( + (s) => + s.space.language === + siteSpace.space.language + ).length > 1 + ) ?? false + } + withSections={withSections} + section={sections?.current} spaceTitle={siteSpace.title} siteSpaceId={siteSpace.id} + siteSpaceIds={siteSpaces + .filter( + (s) => + s.space.language === + siteSpace.space.language + ) + .map((s) => s.id)} className="max-lg:hidden" viewport="desktop" /> diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 24af6ff9ce..fcef5d7dfd 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -40,6 +40,7 @@ export const variantClasses = { 'bg-transparent', 'text-tint', 'border-0', + 'contrast-more:border', 'shadow-none!', 'hover:bg-tint-hover', 'hover:text-tint-strong', @@ -80,9 +81,9 @@ export const variantClasses = { ], }; -const activeClasses = { +export const activeClasses = { primary: 'bg-primary-solid-hover', - blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active', + blank: 'bg-primary-active contrast-more:bg-primary-12 contrast-more:text-contrast-primary-12 disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active', secondary: 'bg-tint-active disabled:bg-tint-active', header: 'bg-header-link/3', }; @@ -134,7 +135,7 @@ export const Button = React.forwardRef< typeof icon === 'string' ? ( ) : ( icon @@ -154,6 +155,7 @@ export const Button = React.forwardRef< aria-label={label?.toString()} aria-pressed={active} target={target} + data-active={active} {...rest} > {content} @@ -166,6 +168,7 @@ export const Button = React.forwardRef< aria-label={label?.toString()} aria-pressed={active} disabled={disabled} + data-active={active} {...rest} > {content} diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 3733ffc4c3..0f6e69527e 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Icon } from '@gitbook/icons'; +import { Icon, type IconName } from '@gitbook/icons'; import type { DetailedHTMLProps, HTMLAttributes } from 'react'; import { createContext, useCallback, useContext, useState } from 'react'; @@ -146,10 +146,20 @@ export function DropdownMenuItem( active?: boolean; className?: ClassValue; children: React.ReactNode; + leadingIcon?: IconName | React.ReactNode; } & LinkInsightsProps & RadixDropdownMenu.DropdownMenuItemProps ) { - const { children, active = false, href, className, insights, target, ...rest } = props; + const { + children, + active = false, + href, + className, + insights, + target, + leadingIcon, + ...rest + } = props; const itemClassName = tcls( 'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center', @@ -161,10 +171,22 @@ export function DropdownMenuItem( className ); + const icon = leadingIcon ? ( + typeof leadingIcon === 'string' ? ( + + ) : ( + leadingIcon + ) + ) : null; + if (href) { return ( + {icon} {children} @@ -173,6 +195,7 @@ export function DropdownMenuItem( return ( + {icon} {children} ); diff --git a/packages/gitbook/src/components/primitives/SegmentedControl.tsx b/packages/gitbook/src/components/primitives/SegmentedControl.tsx new file mode 100644 index 0000000000..f259ee4b95 --- /dev/null +++ b/packages/gitbook/src/components/primitives/SegmentedControl.tsx @@ -0,0 +1,35 @@ +import { type ClassValue, tcls } from '@/lib/tailwind'; +import { Button, type ButtonProps } from './Button'; + +export function SegmentedControl(props: { children: React.ReactNode; className?: ClassValue }) { + const { children, className } = props; + + return ( +
+ {children} +
+ ); +} + +export function SegmentedControlItem(props: ButtonProps) { + const { size = 'medium', className, ...rest } = props; + + return ( +