406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
import { forwardRef, Fragment, Suspense, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
import Highlighter from "react-highlight-words";
|
|
import { usePageContext } from "vike-react/usePageContext";
|
|
import { navigate as routerNavigate } from "vike/client/router";
|
|
// import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
import {
|
|
type AutocompleteApi,
|
|
type AutocompleteCollection,
|
|
type AutocompleteState,
|
|
createAutocomplete,
|
|
} from "@algolia/autocomplete-core";
|
|
import { Dialog, DialogPanel } from "@headlessui/react";
|
|
import clsx from "clsx";
|
|
|
|
import { navigation } from "@/lib/navigation";
|
|
import { onSearch } from "./Search.telefunc";
|
|
|
|
import type { SearchResult } from "@/lib/search";
|
|
|
|
type EmptyObject = Record<string, never>;
|
|
|
|
type Autocomplete = AutocompleteApi<SearchResult, React.SyntheticEvent, React.MouseEvent, React.KeyboardEvent>;
|
|
|
|
function SearchIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
|
return (
|
|
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
|
|
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function useAutocomplete({ close }: { close: (autocomplete: Autocomplete) => void }) {
|
|
let id = useId();
|
|
// let router = useRouter();
|
|
let [autocompleteState, setAutocompleteState] = useState<AutocompleteState<SearchResult> | EmptyObject>({});
|
|
|
|
function navigate({ itemUrl }: { itemUrl?: string }) {
|
|
if (!itemUrl) {
|
|
return;
|
|
}
|
|
|
|
// router.push(itemUrl);
|
|
|
|
if (itemUrl === window.location.pathname + window.location.search + window.location.hash) {
|
|
close(autocomplete);
|
|
}
|
|
}
|
|
|
|
let [autocomplete] = useState<Autocomplete>(() =>
|
|
createAutocomplete<SearchResult, React.SyntheticEvent, React.MouseEvent, React.KeyboardEvent>({
|
|
id,
|
|
placeholder: "Find something...",
|
|
defaultActiveItemId: 0,
|
|
onStateChange({ state }) {
|
|
setAutocompleteState(state);
|
|
},
|
|
shouldPanelOpen({ state }) {
|
|
return state.query !== "";
|
|
},
|
|
navigator: {
|
|
navigate,
|
|
},
|
|
async getSources({ query }) {
|
|
return onSearch(query).then((searchResult) => {
|
|
return [
|
|
{
|
|
sourceId: "documentation",
|
|
getItems() {
|
|
console.log({ searchResult });
|
|
return [];
|
|
return searchResult;
|
|
},
|
|
getItemUrl({ item }) {
|
|
return item.url;
|
|
},
|
|
onSelect: navigate,
|
|
},
|
|
];
|
|
});
|
|
},
|
|
}),
|
|
);
|
|
|
|
return { autocomplete, autocompleteState };
|
|
}
|
|
|
|
function LoadingIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
|
let id = useId();
|
|
|
|
return (
|
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
|
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
|
|
<path stroke={`url(#${id})`} strokeLinecap="round" strokeLinejoin="round" d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5" />
|
|
<defs>
|
|
<linearGradient id={id} x1="13" x2="9.5" y1="9" y2="15" gradientUnits="userSpaceOnUse">
|
|
<stop stopColor="currentColor" />
|
|
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
|
return (
|
|
<Highlighter
|
|
highlightClassName="group-aria-selected:underline bg-transparent text-violet-600 dark:text-violet-400"
|
|
searchWords={[query]}
|
|
autoEscape={true}
|
|
textToHighlight={text}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SearchResult({
|
|
result,
|
|
autocomplete,
|
|
collection,
|
|
query,
|
|
}: {
|
|
result: Result;
|
|
autocomplete: Autocomplete;
|
|
collection: AutocompleteCollection<Result>;
|
|
query: string;
|
|
}) {
|
|
let id = useId();
|
|
|
|
let sectionTitle = navigation.find((section) =>
|
|
section.links.find((link) => link.href === result.url.split("#")[0]),
|
|
)?.title;
|
|
let hierarchy = [sectionTitle, result.pageTitle].filter((x): x is string => typeof x === "string");
|
|
|
|
return (
|
|
<li
|
|
className="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 dark:aria-selected:bg-slate-700/30"
|
|
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
|
{...autocomplete.getItemProps({
|
|
item: result,
|
|
source: collection.source,
|
|
})}
|
|
>
|
|
<div
|
|
id={`${id}-title`}
|
|
aria-hidden="true"
|
|
className="text-sm text-slate-700 group-aria-selected:text-violet-600 dark:text-slate-300 dark:group-aria-selected:text-violet-400"
|
|
>
|
|
<HighlightQuery text={result.title} query={query} />
|
|
</div>
|
|
{hierarchy.length > 0 && (
|
|
<div
|
|
id={`${id}-hierarchy`}
|
|
aria-hidden="true"
|
|
className="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
|
>
|
|
{hierarchy.map((item, itemIndex, items) => (
|
|
<Fragment key={itemIndex}>
|
|
<HighlightQuery text={item} query={query} />
|
|
<span className={itemIndex === items.length - 1 ? "sr-only" : "mx-2 text-slate-300 dark:text-slate-700"}>
|
|
/
|
|
</span>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SearchResults({
|
|
autocomplete,
|
|
query,
|
|
collection,
|
|
}: {
|
|
autocomplete: Autocomplete;
|
|
query: string;
|
|
collection: AutocompleteCollection<Result>;
|
|
}) {
|
|
if (collection.items.length === 0) {
|
|
return (
|
|
<p className="px-4 py-8 text-center text-sm text-slate-700 dark:text-slate-400">
|
|
No results for “
|
|
<span className="break-words text-slate-900 dark:text-white">{query}</span>
|
|
”
|
|
</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ul {...autocomplete.getListProps()}>
|
|
{collection.items.map((result) => (
|
|
<SearchResult
|
|
key={result.url}
|
|
result={result}
|
|
autocomplete={autocomplete}
|
|
collection={collection}
|
|
query={query}
|
|
/>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
const SearchInput = forwardRef<
|
|
React.ComponentRef<"input">,
|
|
{
|
|
autocomplete: Autocomplete;
|
|
autocompleteState: AutocompleteState<Result> | EmptyObject;
|
|
onClose: () => void;
|
|
}
|
|
>(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
|
|
let inputProps = autocomplete.getInputProps({ inputElement: null });
|
|
|
|
return (
|
|
<div className="group relative flex h-12">
|
|
<SearchIcon className="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400 dark:fill-slate-500" />
|
|
<input
|
|
ref={inputRef}
|
|
data-autofocus
|
|
className={clsx(
|
|
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm dark:text-white [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
|
autocompleteState.status === "stalled" ? "pr-11" : "pr-4",
|
|
)}
|
|
{...inputProps}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape" && !autocompleteState.isOpen && autocompleteState.query === "") {
|
|
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
|
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
|
if (document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
onClose();
|
|
} else {
|
|
inputProps.onKeyDown(event);
|
|
}
|
|
}}
|
|
/>
|
|
{autocompleteState.status === "stalled" && (
|
|
<div className="absolute inset-y-0 right-3 flex items-center">
|
|
<LoadingIcon className="h-6 w-6 animate-spin stroke-slate-200 text-slate-400 dark:stroke-slate-700 dark:text-slate-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function CloseOnNavigation({
|
|
close,
|
|
autocomplete,
|
|
}: {
|
|
close: (autocomplete: Autocomplete) => void;
|
|
autocomplete: Autocomplete;
|
|
}) {
|
|
const { urlParsed } = usePageContext();
|
|
const { pathname, search } = urlParsed;
|
|
|
|
useEffect(() => {
|
|
close(autocomplete);
|
|
}, [pathname, search, close, autocomplete]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function SearchDialog({
|
|
open,
|
|
setOpen,
|
|
className,
|
|
}: {
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
className?: string;
|
|
}) {
|
|
let formRef = useRef<React.ElementRef<"form">>(null);
|
|
let panelRef = useRef<React.ElementRef<"div">>(null);
|
|
let inputRef = useRef<React.ElementRef<typeof SearchInput>>(null);
|
|
|
|
let close = useCallback(
|
|
(autocomplete: Autocomplete) => {
|
|
setOpen(false);
|
|
autocomplete.setQuery("");
|
|
},
|
|
[setOpen],
|
|
);
|
|
|
|
let { autocomplete, autocompleteState } = useAutocomplete({
|
|
close() {
|
|
close(autocomplete);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
return;
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
setOpen(true);
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [open, setOpen]);
|
|
|
|
return (
|
|
<>
|
|
<Suspense fallback={null}>
|
|
<CloseOnNavigation close={close} autocomplete={autocomplete} />
|
|
</Suspense>
|
|
<Dialog open={open} onClose={() => close(autocomplete)} className={clsx("fixed inset-0 z-50", className)}>
|
|
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
|
|
|
|
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
|
|
<DialogPanel className="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl dark:bg-slate-800 dark:ring-1 dark:ring-slate-700">
|
|
<div {...autocomplete.getRootProps({})}>
|
|
<form
|
|
ref={formRef}
|
|
{...autocomplete.getFormProps({
|
|
inputElement: inputRef.current,
|
|
})}
|
|
>
|
|
<SearchInput
|
|
ref={inputRef}
|
|
autocomplete={autocomplete}
|
|
autocompleteState={autocompleteState}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
<div
|
|
ref={panelRef}
|
|
className="border-t border-slate-200 bg-white px-2 py-3 empty:hidden dark:border-slate-400/10 dark:bg-slate-800"
|
|
{...autocomplete.getPanelProps({})}
|
|
>
|
|
{autocompleteState.isOpen && (
|
|
<SearchResults
|
|
autocomplete={autocomplete}
|
|
query={autocompleteState.query}
|
|
collection={autocompleteState.collections[0]}
|
|
/>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</DialogPanel>
|
|
</div>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function useSearchProps() {
|
|
let buttonRef = useRef<React.ElementRef<"button">>(null);
|
|
let [open, setOpen] = useState(false);
|
|
|
|
return {
|
|
buttonProps: {
|
|
ref: buttonRef,
|
|
onClick() {
|
|
setOpen(true);
|
|
},
|
|
},
|
|
dialogProps: {
|
|
open,
|
|
setOpen: useCallback((open: boolean) => {
|
|
let { width = 0, height = 0 } = buttonRef.current?.getBoundingClientRect() ?? {};
|
|
if (!open || (width !== 0 && height !== 0)) {
|
|
setOpen(open);
|
|
}
|
|
}, []),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function Search() {
|
|
let [modifierKey, setModifierKey] = useState<string>();
|
|
let { buttonProps, dialogProps } = useSearchProps();
|
|
|
|
useEffect(() => {
|
|
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl ");
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96 dark:md:bg-slate-800/75 dark:md:ring-white/5 dark:md:ring-inset dark:md:hover:bg-slate-700/40 dark:md:hover:ring-slate-500"
|
|
{...buttonProps}
|
|
>
|
|
<SearchIcon className="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400 dark:fill-slate-500" />
|
|
<span className="sr-only md:not-sr-only md:ml-2 md:text-slate-500 md:dark:text-slate-400">Search docs</span>
|
|
{modifierKey && (
|
|
<kbd className="ml-auto hidden font-medium text-slate-400 md:block dark:text-slate-500">
|
|
<kbd className="font-sans">{modifierKey}</kbd>
|
|
<kbd className="font-sans">K</kbd>
|
|
</kbd>
|
|
)}
|
|
</button>
|
|
<SearchDialog {...dialogProps} />
|
|
</>
|
|
);
|
|
}
|