Skip to content

Commit 37320ad

Browse files
committed
Toggleable layout
1 parent 879d230 commit 37320ad

File tree

4 files changed

+104
-52
lines changed

4 files changed

+104
-52
lines changed

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

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
'use client';
22

3-
import { Icon } from '@gitbook/icons';
4-
import { readStreamableValue } from 'ai/rsc';
5-
import React from 'react';
6-
7-
import { Loading } from '@/components/primitives';
83
import { useLanguage } from '@/intl/client';
94
import { t } from '@/intl/translate';
105
import type { TranslationLanguage } from '@/intl/translations';
116
import { tcls } from '@/lib/tailwind';
7+
import { Icon } from '@gitbook/icons';
8+
import { readStreamableValue } from 'ai/rsc';
9+
import React from 'react';
1210

11+
import { motion } from 'framer-motion';
1312
import { useTrackEvent } from '../Insights';
1413
import { Link } from '../primitives';
1514
import { useSearchAskContext } from './SearchAskContext';
1615
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
1716
import { useSearch, useSearchLink } from './useSearch';
18-
1917
export type SearchAskState =
2018
| {
2119
type: 'answer';
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
8886
}, [setAskState]);
8987

9088
const loading = (
91-
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
92-
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
89+
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
90+
{[...Array(9)].map((_, index) => (
91+
<div
92+
key={index}
93+
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
94+
style={{
95+
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
96+
width: `${((index % 5) + 1) * 15}%`,
97+
}}
98+
/>
99+
))}
93100
</div>
94101
);
95102

96103
return (
97-
<>
104+
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
98105
{askState?.type === 'answer' ? (
99106
<React.Suspense fallback={loading}>
100107
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
104111
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
105112
) : null}
106113
{askState?.type === 'loading' ? loading : null}
107-
</>
114+
</motion.div>
108115
);
109116
}
110117

@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
138145

139146
return (
140147
<>
141-
<div
142-
data-testid="search-ask-answer"
143-
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
144-
>
148+
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
145149
{answer.body ?? t(language, 'search_ask_no_answer')}
146150
{answer.followupQuestions.length > 0 ? (
147151
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />

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

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
77
import { tString, useLanguage } from '@/intl/client';
88
import { tcls } from '@/lib/tailwind';
99

10+
import { Button } from '../primitives/Button';
1011
import { LoadingPane } from '../primitives/LoadingPane';
1112
import { SearchAskAnswer } from './SearchAskAnswer';
1213
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
@@ -33,7 +34,7 @@ export function SearchModal(props: SearchModalProps) {
3334
'mod+k',
3435
(e) => {
3536
e.preventDefault();
36-
setSearchState({ ask: false, query: '', global: false });
37+
setSearchState({ mode: 'both', query: '', global: false });
3738
},
3839
[]
3940
);
@@ -177,7 +178,7 @@ function SearchModalBody(
177178

178179
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
179180
setSearchState({
180-
ask: false, // When typing, we go back to the default search mode
181+
mode: 'both', // When typing, we go back to the default search mode
181182
query: event.target.value,
182183
global: state.global,
183184
});
@@ -288,18 +289,59 @@ function SearchModalBody(
288289
</div>
289290
</div>
290291

291-
<div className="overflow-y-auto md:col-start-1 md:row-start-2">
292-
<SearchResults
293-
ref={resultsRef}
294-
global={isMultiVariants && state.global}
295-
query={normalizedQuery}
296-
withAsk={withAsk}
297-
onSwitchToAsk={onSwitchToAsk}
298-
/>
299-
</div>
300-
<div className="overflow-y-auto border-tint-subtle bg-tint-subtle max-md:border-t md:col-start-2 md:row-start-2 md:border-l">
301-
<SearchAskAnswer query={normalizedQuery} />
302-
</div>
292+
<AnimatePresence>
293+
{state.mode !== 'chat' ? (
294+
<motion.div
295+
key="results"
296+
layout
297+
className={tcls(
298+
'overflow-y-auto md:col-start-1 md:row-start-2',
299+
state.mode === 'results' && 'md:-col-end-1'
300+
)}
301+
initial={{ width: 0 }}
302+
animate={{ width: '100%' }}
303+
exit={{ width: 0 }}
304+
>
305+
<SearchResults
306+
ref={resultsRef}
307+
global={isMultiVariants && state.global}
308+
query={normalizedQuery}
309+
withAsk={withAsk}
310+
onSwitchToAsk={onSwitchToAsk}
311+
/>
312+
</motion.div>
313+
) : null}
314+
315+
{state.mode !== 'results' ? (
316+
<motion.div
317+
key="chat"
318+
layout
319+
className={tcls(
320+
'-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l',
321+
state.mode === 'chat' && 'md:col-start-1'
322+
)}
323+
initial={{ width: 0 }}
324+
animate={{ width: '100%' }}
325+
exit={{ width: 0 }}
326+
>
327+
{state.mode === 'chat' ? (
328+
<Button
329+
icon="right-from-line"
330+
iconOnly
331+
label="Show results"
332+
variant="blank"
333+
className="px-2"
334+
onClick={() => {
335+
setSearchState((prev) =>
336+
prev ? { ...prev, mode: 'both', manual: true } : null
337+
);
338+
}}
339+
/>
340+
) : null}
341+
<SearchAskAnswer query={normalizedQuery} />
342+
</motion.div>
343+
) : null}
344+
</AnimatePresence>
303345
</div>
304346
</motion.div>
305347
);

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

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React from 'react';
77
import { t, useLanguage } from '@/intl/client';
88
import { tcls } from '@/lib/tailwind';
99

10+
import { motion } from 'framer-motion';
1011
import { useTrackEvent } from '../Insights';
1112
import { Loading } from '../primitives';
1213
import { SearchPageResultItem } from './SearchPageResultItem';
@@ -18,6 +19,7 @@ import {
1819
searchSiteSpaceContent,
1920
streamRecommendedQuestions,
2021
} from './server-actions';
22+
import { useSearch } from './useSearch';
2123

2224
export interface SearchResultsRef {
2325
moveUp(): void;
@@ -61,6 +63,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
6163
}>({ results: [], fetching: true });
6264
const [cursor, setCursor] = React.useState<number | null>(null);
6365
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
66+
const [searchState, setSearchState] = useSearch();
6467

6568
React.useEffect(() => {
6669
if (!query) {
@@ -157,9 +160,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
157160
setCursor(null);
158161
} else if (results.length > 0) {
159162
// Auto-focus the first result
163+
setSearchState((prev) => (prev ? { ...prev, mode: 'both' } : null));
160164
setCursor(0);
165+
} else if (results.length === 0 && !resultsState.fetching && !searchState?.manual) {
166+
setSearchState((prev) => (prev ? { ...prev, mode: 'chat' } : null));
161167
}
162-
}, [results, query]);
168+
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
163169

164170
// Scroll to the active result.
165171
React.useEffect(() => {
@@ -210,9 +216,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
210216

211217
if (resultsState.fetching) {
212218
return (
213-
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
219+
<motion.div
220+
className={tcls('flex', 'items-center', 'justify-center', 'p-8')}
221+
layout="position"
222+
>
214223
<Loading className={tcls('w-6', 'text-primary-subtle')} />
215-
</div>
224+
</motion.div>
216225
);
217226
}
218227

@@ -293,16 +302,3 @@ export const SearchResults = React.forwardRef(function SearchResults(
293302
</>
294303
);
295304
});
296-
297-
/**
298-
* Add a "Ask <question>" item at the top of the results list.
299-
*/
300-
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
301-
const without = results.filter((result) => result.type !== 'question');
302-
303-
if (query.length === 0) {
304-
return without;
305-
}
306-
307-
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
308-
}

packages/gitbook/src/components/Search/useSearch.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
1+
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
22
import React from 'react';
33

44
import type { LinkProps } from '../primitives';
55

66
export interface SearchState {
77
query: string;
8-
ask: boolean;
98
global: boolean;
9+
mode: 'results' | 'chat' | 'both';
10+
manual?: boolean;
1011
}
1112

1213
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
1314
const keyMap = {
1415
q: parseAsString,
15-
ask: parseAsBoolean,
16+
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
1617
global: parseAsBoolean,
18+
manual: parseAsBoolean,
1719
};
1820

1921
export type UpdateSearchState = (
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
3335
return null;
3436
}
3537

36-
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
38+
return {
39+
query: rawState.q,
40+
mode: rawState.mode,
41+
global: !!rawState.global,
42+
manual: !!rawState.manual,
43+
};
3744
}, [rawState]);
3845

3946
const stateRef = React.useRef(state);
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
5259
if (update === null) {
5360
return setRawState({
5461
q: null,
55-
ask: null,
62+
mode: null,
5663
global: null,
64+
manual: null,
5765
});
5866
}
5967
return setRawState({
6068
q: update.query,
61-
ask: update.ask ? true : null,
69+
mode: update.mode,
6270
global: update.global ? true : null,
71+
manual: update.manual ? true : null,
6372
});
6473
},
6574
[setRawState]
@@ -78,16 +87,17 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
7887
(query) => {
7988
const searchParams = new URLSearchParams();
8089
searchParams.set('q', query.query ?? '');
81-
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
90+
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
8291
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
92+
searchParams.delete('manual');
8393
return {
8494
href: `?${searchParams.toString()}`,
8595
prefetch: false,
8696
onClick: (event) => {
8797
event.preventDefault();
8898
setSearch((prev) => ({
8999
query: '',
90-
ask: false,
100+
mode: 'both',
91101
global: false,
92102
...(prev ?? {}),
93103
...query,

0 commit comments

Comments
 (0)