Skip to content

Commit f8138da

Browse files
committed
Scope search across sections and variants
**Components:** - Add `data-active` to `Button` to make styling easier - Improve active style for blank buttons in `contrast-more` mode - Add leading icon to `DropdownMenuItem`, to be further standardised in other places in follow-up PR - Create `SegmentedControl` component that styles buttons to look like one big selector. **Search**: - Rework `global` search parameter into two separate ones to determine search scope: - `scope: all | current`: how "wide" to search on the site. Determines section scope. - `depth: single | full`: how "deep" to search within the scope. This determines the variant scope within sections. - Change `SearchScopeToggle` to use new scope & depth params and segmented control to show a UI to switch between scopes.
1 parent 8ff1e3b commit f8138da

File tree

13 files changed

+312
-82
lines changed

13 files changed

+312
-82
lines changed

.changeset/wicked-hornets-crash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": minor
3+
---
4+
5+
Scope search across sections and variants

packages/gitbook/src/components/AI/useAI.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ export function useAI(): AIContext {
137137
setSearchState((prev) => ({
138138
ask: null, // Reset ask as we assume the assistant will handle it
139139
query: prev?.query ?? null,
140-
global: prev?.global ?? false,
140+
scope: prev?.scope ?? 'all',
141+
depth: prev?.depth ?? 'single',
141142
open: false,
142143
}));
143144
assistant.open(query);

packages/gitbook/src/components/AI/useAIChat.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ export function AIChatProvider(props: {
146146
setSearchState((prev) => ({
147147
ask: prev?.ask ?? initialQuery ?? '',
148148
query: prev?.query ?? null,
149-
global: prev?.global ?? false,
149+
scope: prev?.scope ?? 'all',
150+
depth: prev?.depth ?? 'single',
150151
open: false, // Close search popover when opening chat
151152
}));
152153
}, [setSearchState]);
@@ -159,7 +160,8 @@ export function AIChatProvider(props: {
159160
setSearchState((prev) => ({
160161
ask: null,
161162
query: prev?.query ?? null,
162-
global: prev?.global ?? false,
163+
scope: prev?.scope ?? 'all',
164+
depth: prev?.depth ?? 'single',
163165
open: false,
164166
}));
165167
}, [setSearchState]);
@@ -374,7 +376,8 @@ export function AIChatProvider(props: {
374376
setSearchState((prev) => ({
375377
ask: input.message,
376378
query: prev?.query ?? null,
377-
global: prev?.global ?? false,
379+
scope: prev?.scope ?? 'all',
380+
depth: prev?.depth ?? 'single',
378381
open: false,
379382
}));
380383
}
@@ -435,7 +438,8 @@ export function AIChatProvider(props: {
435438
setSearchState((prev) => ({
436439
ask: '',
437440
query: prev?.query ?? null,
438-
global: prev?.global ?? false,
441+
scope: prev?.scope ?? 'all',
442+
depth: prev?.depth ?? 'single',
439443
open: false,
440444
}));
441445
}, [setSearchState]);

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,28 @@ export function Header(props: {
120120
>
121121
<SearchContainer
122122
style={customization.styling.search}
123-
isMultiVariants={siteSpaces.length > 1}
123+
withVariants={withVariants === 'generic'}
124+
withSiteVariants={
125+
sections?.list.some(
126+
(s) =>
127+
s.object === 'site-section' &&
128+
s.siteSpaces.filter(
129+
(s) => s.space.language === siteSpace.space.language
130+
).length > 1
131+
) ?? false
132+
}
133+
withSections={!!sections}
134+
section={
135+
sections
136+
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
137+
encodeClientSiteSections(context, sections).current
138+
: undefined
139+
}
124140
spaceTitle={siteSpace.title}
125141
siteSpaceId={siteSpace.id}
142+
siteSpaceIds={siteSpaces
143+
.filter((s) => s.space.language === siteSpace.space.language)
144+
.map((s) => s.id)}
126145
viewport={!withTopHeader ? 'mobile' : undefined}
127146
/>
128147
</div>

packages/gitbook/src/components/Search/SearchContainer.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { CustomizationSearchStyle } from '@gitbook/api';
3+
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
44
import { useRouter } from 'next/navigation';
55
import React, { useRef } from 'react';
66
import { useHotkeys } from 'react-hotkeys-hook';
@@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
1818
import { useSearch } from './useSearch';
1919

2020
interface SearchContainerProps {
21+
/** The current site space id. */
2122
siteSpaceId: string;
23+
24+
/** The title of the current space. */
2225
spaceTitle: string;
23-
isMultiVariants: boolean;
26+
27+
/** The ids of all spaces in the current section. */
28+
siteSpaceIds: string[];
29+
30+
/** Whether there are sections on the site. */
31+
withSections: boolean;
32+
33+
/** The current section, displayed in search scope toggle. */
34+
section?: Pick<SiteSection, 'title' | 'icon'>;
35+
36+
/** Whether the current section has variants. */
37+
withVariants: boolean;
38+
39+
/** Whether any section on the site has variants. */
40+
withSiteVariants: boolean;
41+
2442
style: CustomizationSearchStyle;
2543
className?: string;
2644
viewport?: 'desktop' | 'mobile';
@@ -30,7 +48,18 @@ interface SearchContainerProps {
3048
* Client component to render the search input and results.
3149
*/
3250
export function SearchContainer(props: SearchContainerProps) {
33-
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
51+
const {
52+
siteSpaceId,
53+
spaceTitle,
54+
section,
55+
withVariants,
56+
withSiteVariants,
57+
withSections,
58+
style,
59+
className,
60+
viewport,
61+
siteSpaceIds,
62+
} = props;
3463

3564
const { assistants } = useAI();
3665

@@ -108,7 +137,8 @@ export function SearchContainer(props: SearchContainerProps) {
108137
}
109138
setSearchState((prev) => ({
110139
ask: withAI ? (prev?.ask ?? null) : null,
111-
global: prev?.global ?? false,
140+
scope: prev?.scope ?? 'all',
141+
depth: prev?.depth ?? 'single',
112142
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
113143
open: true,
114144
}));
@@ -148,7 +178,8 @@ export function SearchContainer(props: SearchContainerProps) {
148178
setSearchState((prev) => ({
149179
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
150180
query: value,
151-
global: prev?.global ?? false,
181+
scope: prev?.scope ?? 'all',
182+
depth: prev?.depth ?? 'single',
152183
open: true,
153184
}));
154185
};
@@ -168,15 +199,23 @@ export function SearchContainer(props: SearchContainerProps) {
168199
// Only show content if there's a query or Ask is enabled
169200
state?.query || withAI ? (
170201
<React.Suspense fallback={null}>
171-
{isMultiVariants && !showAsk ? (
172-
<SearchScopeToggle spaceTitle={spaceTitle} />
202+
{(withVariants || withSections) && !showAsk ? (
203+
<SearchScopeToggle
204+
section={section}
205+
spaceTitle={spaceTitle}
206+
withVariants={withVariants}
207+
withSiteVariants={withSiteVariants}
208+
withSections={withSections}
209+
/>
173210
) : null}
174211
{state !== null && !showAsk ? (
175212
<SearchResults
176213
ref={resultsRef}
177214
query={normalizedQuery}
178-
global={state?.global ?? false}
215+
scope={state?.scope ?? 'auto'}
216+
depth={state?.depth ?? 'single'}
179217
siteSpaceId={siteSpaceId}
218+
siteSpaceIds={siteSpaceIds}
180219
/>
181220
) : null}
182221
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
@@ -194,7 +233,7 @@ export function SearchContainer(props: SearchContainerProps) {
194233
onOpenAutoFocus: (event) => event.preventDefault(),
195234
align: 'start',
196235
className:
197-
'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)]',
236+
'@container 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)]',
198237
onInteractOutside: (event) => {
199238
// Don't close if clicking on the search input itself
200239
if (searchInputRef.current?.contains(event.target as Node)) {

packages/gitbook/src/components/Search/SearchResults.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
1717
import {
1818
type OrderedComputedResult,
1919
searchAllSiteContent,
20-
searchSiteSpaceContent,
20+
searchCurrentSiteSpaceContent,
21+
searchSpecificSiteSpaceContent,
2122
streamRecommendedQuestions,
2223
} from './server-actions';
24+
import type { SearchDepth, SearchScope } from './useSearch';
2325

2426
export interface SearchResultsRef {
2527
moveUp(): void;
@@ -50,12 +52,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
5052
props: {
5153
children?: React.ReactNode;
5254
query: string;
53-
global: boolean;
55+
scope: SearchScope;
56+
depth: SearchDepth;
5457
siteSpaceId: string;
58+
siteSpaceIds: string[];
5559
},
5660
ref: React.Ref<SearchResultsRef>
5761
) {
58-
const { children, query, global, siteSpaceId } = props;
62+
const { children, query, scope, depth, siteSpaceId, siteSpaceIds } = props;
5963

6064
const language = useLanguage();
6165
const trackEvent = useTrackEvent();
@@ -133,9 +137,23 @@ export const SearchResults = React.forwardRef(function SearchResults(
133137
setResultsState((prev) => ({ results: prev.results, fetching: true }));
134138
let cancelled = false;
135139
const timeout = setTimeout(async () => {
136-
const results = await (global
137-
? searchAllSiteContent(query)
138-
: searchSiteSpaceContent(query));
140+
const results = await (() => {
141+
if (scope === 'all' && depth === 'single') {
142+
return searchCurrentSiteSpaceContent(query, siteSpaceId);
143+
}
144+
if (scope === 'all' && depth === 'full') {
145+
return searchAllSiteContent(query);
146+
}
147+
if (scope === 'current' && depth === 'single') {
148+
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
149+
}
150+
if (scope === 'current' && depth === 'full') {
151+
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
152+
}
153+
throw new Error(
154+
`Unhandled search scope/depth combination: scope=${scope}, depth=${depth}`
155+
);
156+
})();
139157

140158
if (cancelled) {
141159
return;
@@ -158,7 +176,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
158176
cancelled = true;
159177
clearTimeout(timeout);
160178
};
161-
}, [query, global, trackEvent, withAI, siteSpaceId]);
179+
}, [query, scope, depth, trackEvent, withAI, siteSpaceId]);
162180

163181
const results: ResultType[] = React.useMemo(() => {
164182
if (!withAI) {
Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,77 @@
1-
import { tString, useLanguage } from '@/intl/client';
2-
import { Button } from '../primitives';
1+
'use client';
2+
3+
import type { SiteSection } from '@gitbook/api';
4+
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
35
import { useSearch } from './useSearch';
46

57
/**
68
* Toolbar to toggle between search modes (global or scoped to a space).
79
* Only visible when the space is in a collection.
810
*/
9-
export function SearchScopeToggle(props: { spaceTitle: string }) {
10-
const { spaceTitle } = props;
11+
export function SearchScopeToggle(props: {
12+
spaceTitle: string;
13+
section?: Pick<SiteSection, 'title' | 'icon'>;
14+
withVariants: boolean;
15+
withSiteVariants: boolean;
16+
withSections: boolean;
17+
}) {
18+
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
1119
const [state, setSearchState] = useSearch();
12-
const language = useLanguage();
1320

1421
if (!state) {
1522
return null;
1623
}
1724

1825
return (
19-
<div
20-
role="toolbar"
21-
aria-orientation="horizontal"
22-
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
23-
>
24-
<Button
25-
variant="blank"
26-
size="medium"
27-
className="shrink grow justify-center whitespace-normal"
28-
active={!state.global}
29-
label={tString(language, 'search_scope_space', spaceTitle)}
30-
onClick={() => {
31-
setSearchState({
32-
...state,
33-
global: false,
34-
});
35-
}}
36-
/>
37-
<Button
38-
variant="blank"
39-
size="medium"
40-
className="shrink grow justify-center whitespace-normal"
41-
active={state.global}
42-
label={tString(language, 'search_scope_all')}
43-
onClick={() => {
44-
setSearchState({
45-
...state,
46-
global: true,
47-
});
48-
}}
49-
/>
50-
</div>
26+
<>
27+
{withSections ? (
28+
<SegmentedControl className="animate-scale-in @max-md:flex-col">
29+
<SegmentedControlItem
30+
active={state.scope === 'current'}
31+
icon={section?.icon}
32+
label={section?.title}
33+
onClick={() =>
34+
setSearchState({ ...state, scope: 'current', depth: 'single' })
35+
}
36+
/>
37+
<SegmentedControlItem
38+
active={state.scope === 'all' && state.depth === 'single'}
39+
label={withSiteVariants ? 'Most relevant' : 'Entire site'}
40+
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
41+
onClick={() => setSearchState({ ...state, scope: 'all', depth: 'single' })}
42+
/>
43+
{withSiteVariants ? (
44+
<SegmentedControlItem
45+
active={state.scope === 'all' && state.depth === 'full'}
46+
label="Entire site"
47+
icon="infinity"
48+
onClick={() =>
49+
setSearchState({ ...state, scope: 'all', depth: 'full' })
50+
}
51+
/>
52+
) : null}
53+
</SegmentedControl>
54+
) : null}
55+
{withVariants && (!withSections || state.scope === 'current') ? (
56+
<SegmentedControl className="animate-scale-in">
57+
<SegmentedControlItem
58+
size={state.scope === 'current' ? 'small' : 'medium'}
59+
active={state.depth === 'single'}
60+
className="py-1"
61+
label={spaceTitle}
62+
icon="crosshairs"
63+
onClick={() => setSearchState({ ...state, depth: 'single' })}
64+
/>
65+
<SegmentedControlItem
66+
size={state.scope === 'current' ? 'small' : 'medium'}
67+
active={state.depth === 'full'}
68+
className="py-1"
69+
label="All content"
70+
icon="rectangle-vertical-history"
71+
onClick={() => setSearchState({ ...state, depth: 'full' })}
72+
/>
73+
</SegmentedControl>
74+
) : null}
75+
</>
5176
);
5277
}

0 commit comments

Comments
 (0)