Toggleable layout

This commit is contained in:
Zeno Kapitein
2025-05-12 13:22:12 +02:00
parent cced4f0d78
commit 34dceb2f97
4 changed files with 104 additions and 52 deletions
@@ -1,21 +1,19 @@
'use client';
import { Icon } from '@gitbook/icons';
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { Loading } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { readStreamableValue } from 'ai/rsc';
import React from 'react';
import { motion } from 'framer-motion';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch';
export type SearchAskState =
| {
type: 'answer';
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
}, [setAskState]);
const loading = (
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
{[...Array(9)].map((_, index) => (
<div
key={index}
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"
style={{
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
width: `${((index % 5) + 1) * 15}%`,
}}
/>
))}
</div>
);
return (
<>
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
{askState?.type === 'answer' ? (
<React.Suspense fallback={loading}>
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
) : null}
{askState?.type === 'loading' ? loading : null}
</>
</motion.div>
);
}
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
return (
<>
<div
data-testid="search-ask-answer"
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
>
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
{answer.body ?? t(language, 'search_ask_no_answer')}
{answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
@@ -7,6 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Button } from '../primitives/Button';
import { LoadingPane } from '../primitives/LoadingPane';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
@@ -33,7 +34,7 @@ export function SearchModal(props: SearchModalProps) {
'mod+k',
(e) => {
e.preventDefault();
setSearchState({ ask: false, query: '', global: false });
setSearchState({ mode: 'both', query: '', global: false });
},
[]
);
@@ -177,7 +178,7 @@ function SearchModalBody(
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchState({
ask: false, // When typing, we go back to the default search mode
mode: 'both', // When typing, we go back to the default search mode
query: event.target.value,
global: state.global,
});
@@ -288,18 +289,59 @@ function SearchModalBody(
</div>
</div>
<div className="overflow-y-auto md:col-start-1 md:row-start-2">
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
</div>
<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">
<SearchAskAnswer query={normalizedQuery} />
</div>
<AnimatePresence>
{state.mode !== 'chat' ? (
<motion.div
key="results"
layout
className={tcls(
'overflow-y-auto md:col-start-1 md:row-start-2',
state.mode === 'results' && 'md:-col-end-1'
)}
initial={{ width: 0 }}
animate={{ width: '100%' }}
exit={{ width: 0 }}
>
<SearchResults
ref={resultsRef}
global={isMultiVariants && state.global}
query={normalizedQuery}
withAsk={withAsk}
onSwitchToAsk={onSwitchToAsk}
/>
</motion.div>
) : null}
{state.mode !== 'results' ? (
<motion.div
key="chat"
layout
className={tcls(
'-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',
state.mode === 'chat' && 'md:col-start-1'
)}
initial={{ width: 0 }}
animate={{ width: '100%' }}
exit={{ width: 0 }}
>
{state.mode === 'chat' ? (
<Button
icon="right-from-line"
iconOnly
label="Show results"
variant="blank"
className="px-2"
onClick={() => {
setSearchState((prev) =>
prev ? { ...prev, mode: 'both', manual: true } : null
);
}}
/>
) : null}
<SearchAskAnswer query={normalizedQuery} />
</motion.div>
) : null}
</AnimatePresence>
</div>
</motion.div>
);
@@ -7,6 +7,7 @@ import React from 'react';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { motion } from 'framer-motion';
import { useTrackEvent } from '../Insights';
import { Loading } from '../primitives';
import { SearchPageResultItem } from './SearchPageResultItem';
@@ -18,6 +19,7 @@ import {
searchSiteSpaceContent,
streamRecommendedQuestions,
} from './server-actions';
import { useSearch } from './useSearch';
export interface SearchResultsRef {
moveUp(): void;
@@ -61,6 +63,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
}>({ results: [], fetching: true });
const [cursor, setCursor] = React.useState<number | null>(null);
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
const [searchState, setSearchState] = useSearch();
React.useEffect(() => {
if (!query) {
@@ -157,9 +160,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
setCursor(null);
} else if (results.length > 0) {
// Auto-focus the first result
setSearchState((prev) => (prev ? { ...prev, mode: 'both' } : null));
setCursor(0);
} else if (results.length === 0 && !resultsState.fetching && !searchState?.manual) {
setSearchState((prev) => (prev ? { ...prev, mode: 'chat' } : null));
}
}, [results, query]);
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
// Scroll to the active result.
React.useEffect(() => {
@@ -210,9 +216,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
if (resultsState.fetching) {
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
<motion.div
className={tcls('flex', 'items-center', 'justify-center', 'p-8')}
layout="position"
>
<Loading className={tcls('w-6', 'text-primary-subtle')} />
</div>
</motion.div>
);
}
@@ -293,16 +302,3 @@ export const SearchResults = React.forwardRef(function SearchResults(
</>
);
});
/**
* Add a "Ask <question>" item at the top of the results list.
*/
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
const without = results.filter((result) => result.type !== 'question');
if (query.length === 0) {
return without;
}
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
}
@@ -1,19 +1,21 @@
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
export interface SearchState {
query: string;
ask: boolean;
global: boolean;
mode: 'results' | 'chat' | 'both';
manual?: boolean;
}
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
const keyMap = {
q: parseAsString,
ask: parseAsBoolean,
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
global: parseAsBoolean,
manual: parseAsBoolean,
};
export type UpdateSearchState = (
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
return null;
}
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
return {
query: rawState.q,
mode: rawState.mode,
global: !!rawState.global,
manual: !!rawState.manual,
};
}, [rawState]);
const stateRef = React.useRef(state);
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
if (update === null) {
return setRawState({
q: null,
ask: null,
mode: null,
global: null,
manual: null,
});
}
return setRawState({
q: update.query,
ask: update.ask ? true : null,
mode: update.mode,
global: update.global ? true : null,
manual: update.manual ? true : null,
});
},
[setRawState]
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
(query) => {
const searchParams = new URLSearchParams();
searchParams.set('q', query.query ?? '');
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
searchParams.delete('manual');
return {
href: `?${searchParams.toString()}`,
prefetch: false,
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
event.preventDefault();
setSearch((prev) => ({
query: '',
ask: false,
mode: 'both',
global: false,
...(prev ?? {}),
...query,