Files
gitbook/src/components/Search/SearchModal.tsx
T
Sebastian Graz e5fc5c2d2a Style sprint (#126)
* V1 code blocks fix

* equalize modal text label + fix safari bug with fixed position stroke

* code block adjustments

* fix search result spacing
2024-01-31 23:20:09 +01:00

256 lines
8.4 KiB
TypeScript

'use client';
import IconSearch from '@geist-ui/icons/search';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilValue } from 'recoil';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { SearchAskAnswer, searchAskState } from './SearchAskAnswer';
import { SearchResults, SearchResultsRef } from './SearchResults';
import { SearchScopeToggle } from './SearchScopeToggle';
import { SearchState, useSearch } from './useSearch';
import { LoadingPane } from '../primitives/LoadingPane';
interface SearchModalProps {
spaceId: string;
spaceTitle: string;
collectionId: string | null;
withAsk: boolean;
}
export function SearchModal(props: SearchModalProps) {
const [state, setSearchState] = useSearch();
const askState = useRecoilValue(searchAskState);
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.classList.add('search-open');
document.body.style.overflow = 'hidden';
}
return () => {
document.body.classList.remove('search-open');
document.body.style.overflow = 'auto';
};
}, [isSearchOpened]);
if (state === null) {
return null;
}
const onChangeQuery = (newQuery: SearchState) => {
setSearchState(newQuery);
};
const onClose = async (to?: string) => {
await setSearchState(null);
if (to) {
router.push(to);
}
};
return (
<>
<div
role="dialog"
className={tcls(
'flex',
'items-start',
'justify-center',
'fixed',
'inset-0',
'bg-dark/4',
'backdrop-blur-2xl',
'opacity-[1]',
'z-30',
'px-4',
'pt-4',
'dark:bg-dark/8',
'md:pt-[min(8vw,_6rem)]',
)}
onClick={() => {
onClose();
}}
>
<AnimatePresence>
{askState?.type === 'loading' ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={tcls(
'w-[100vw]',
'h-[100vh]',
'fixed',
'inset-0',
'z-10',
'pointer-events-none',
)}
>
<LoadingPane
gridStyle={['h-[100vh]', 'aspect-auto', 'top-[-30%]']}
pulse
tile={96}
style={['grid']}
/>
</motion.div>
) : null}
</AnimatePresence>
<SearchModalBody
{...props}
state={state}
onChangeQuery={onChangeQuery}
onClose={onClose}
/>
</div>
</>
);
}
function SearchModalBody(
props: SearchModalProps & {
state: SearchState;
onChangeQuery: (newQuery: SearchState) => void;
onClose: (to?: string) => void;
},
) {
const { spaceId, spaceTitle, withAsk, collectionId, state, onChangeQuery, onClose } = props;
const language = useLanguage();
const resultsRef = React.useRef<SearchResultsRef>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
onClose();
} else 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<HTMLInputElement>) => {
onChangeQuery({
ask: false, // When typing, we go back to the default search mode
query: event.target.value,
global: state.global,
});
};
return (
<div
role="dialog"
aria-label={tString(language, 'search')}
className={tcls(
'z-40',
'flex',
'flex-col',
'bg-white',
'max-w-[768px]',
'mt-[-1px]',
'w-full',
'max-h',
'rounded-lg',
'ring-1',
'ring-dark/1',
'shadow-2xl',
'backdrop-blur-lg',
'overflow-hidden',
'dark:ring-inset',
'dark:bg-dark-4',
'dark:ring-light/2',
)}
onClick={(event) => {
event.stopPropagation();
}}
>
<div className={tcls('flex', 'flex-row', 'items-center')}>
<div className={tcls('text-dark/4', 'p-3', 'dark:text-light/5')}>
<IconSearch className={tcls('w-6', 'h-6')} />
</div>
<div className={tcls('flex-1')}>
<input
ref={inputRef}
value={state.query}
onKeyDown={onKeyDown}
onChange={onChange}
className={tcls(
'text-dark',
'placeholder:text-dark/7',
'flex',
'resize-none',
'w-full',
'h-12',
'p-2',
'focus:outline-none',
'bg-transparent',
'whitespace-pre-line',
'dark:text-light',
'dark:placeholder:text-light/7',
)}
placeholder={tString(language, 'search_input_placeholder')}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
/>
</div>
</div>
{!state.ask || !withAsk ? (
<>
<SearchResults
ref={resultsRef}
spaceId={spaceId}
collectionId={state.global ? collectionId : null}
query={state.query}
withAsk={withAsk}
onSwitchToAsk={() => {
onChangeQuery({
ask: true,
query: state.query,
global: state.global,
});
}}
onClose={onClose}
>
{collectionId && state.query ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
) : null}
</SearchResults>
</>
) : null}
{state.query && state.ask && withAsk ? (
<SearchAskAnswer spaceId={spaceId} query={state.query} />
) : null}
</div>
);
}