From b8a8213f27d74faabe77ec48fbfeb1178be93b18 Mon Sep 17 00:00:00 2001 From: GauthierWebDev Date: Sun, 20 Apr 2025 02:55:34 +0200 Subject: [PATCH] feat: Add Search component with search functionality --- app/components/Search.tsx | 334 ++++++++++++++++++++++++++++++++++ app/hooks/useDebounce.tsx | 23 +++ app/layouts/LayoutDefault.tsx | 17 +- 3 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 app/components/Search.tsx create mode 100644 app/hooks/useDebounce.tsx diff --git a/app/components/Search.tsx b/app/components/Search.tsx new file mode 100644 index 0000000..9b5f96d --- /dev/null +++ b/app/components/Search.tsx @@ -0,0 +1,334 @@ +import type { SearchResult } from "@/services/FlexSearchService"; +import type { JSX, Accessor, Setter } from "solid-js"; + +// import React, { +// useId, +// createSignal, +// createEffect, +// createContext, +// useContext, +// Fragment, +// } from "react"; +import { + createUniqueId, + createContext, + useContext, + For, + createEffect, + createSignal, +} from "solid-js"; +import { Dialog, DialogPanel } from "terracotta"; +import { useDebounce } from "@/hooks/useDebounce"; +// import Highlighter from "react-highlight-words"; +// import { navigation } from "@/lib/navigation"; +import { navigate } from "vike/client/router"; +import { onSearch } from "./Search.telefunc"; +import clsx from "clsx"; + +const SearchContext = createContext<{ + query: Accessor; + close: () => void; + results: Accessor; + isLoading: Accessor; + isOpened: Accessor; + setQuery: (query: string) => void; + setIsOpened: (isOpened: boolean) => void; + setIsLoading: (isLoading: boolean) => void; + setResults: (results: SearchResult[]) => void; +}>({ + query: () => "", + close: () => {}, + results: () => [], + isLoading: () => false, + isOpened: () => false, + setQuery: () => {}, + setIsOpened: () => {}, + setIsLoading: () => {}, + setResults: () => {}, +}); + +function SearchIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + ); +} + +function LoadingIcon(props: JSX.IntrinsicElements["svg"]) { + const id = createUniqueId(); + + return ( + + ); +} + +function SearchInput() { + const { close, setQuery, query, isLoading } = useContext(SearchContext); + + return ( +
+ + { + if (event.key === "Escape") { + // 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(); + } + + close(); + } + }} + value={query()} + onChange={(event) => setQuery(event.currentTarget.value)} + /> + {isLoading() && ( +
+ +
+ )} +
+ ); +} + +function HighlightQuery({ text, query }: { text: string; query: string }) { + return {text}; + + // return ( + // + // ); +} + +function SearchResultItem(props: { result: SearchResult; query: string }) { + const { close } = useContext(SearchContext); + const id = createUniqueId(); + + // const sectionTitle = navigation.find((section) => + // section.links.find((link) => link.href === result.url.split("#")[0]), + // )?.title; + + // const hierarchy = [sectionTitle, result.pageTitle].filter( + // (x): x is string => typeof x === "string", + // ); + + return ( +
  • { + if (event.key === "Enter") { + navigate(props.result.url); + close(); + } + }} + onClick={() => { + navigate(props.result.url); + close(); + }} + > + + {/* {props.result.length > 0 && ( + + )} */} +
  • + ); +} + +function SearchResults() { + const { results, query } = useContext(SearchContext); + + if (results.length === 0) { + return ( +

    + Aucun résultat pour “ + + {query()} + + ” +

    + ); + } + + return ( +
      + + {(result) => ( +
    • + +
    • + )} +
      +
    + ); +} + +function SearchDialog(props: { class?: string }) { + const { close, isOpened, setIsOpened, results } = useContext(SearchContext); + + createEffect(() => { + if (isOpened()) return; + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setIsOpened(true); + } + } + + window.addEventListener("keydown", onKeyDown); + + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [isOpened, setIsOpened]); + + return ( + <> + +
    + +
    + +
    event.preventDefault()}> + +
    + {results.length > 0 && } +
    + +
    +
    +
    + + ); +} + +export function Search() { + const [results, setResults] = createSignal([]); + const [debouncedQuery, setDebouncedQuery] = useDebounce(""); + const [modifierKey, setModifierKey] = createSignal(); + const [isLoading, setIsLoading] = createSignal(false); + const [isOpened, setIsOpened] = createSignal(false); + const [query, setQuery] = createSignal(""); + + createEffect(() => { + const platform = navigator.userAgentData?.platform || navigator.platform; + setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl "); + }, []); + + createEffect(() => { + setDebouncedQuery(query()); + }, [query()]); + + createEffect(() => { + if (debouncedQuery.length === 0) { + setIsLoading(false); + setResults([]); + return; + } + + setIsLoading(true); + + onSearch(debouncedQuery()) + .then(setResults) + .finally(() => { + setIsLoading(false); + }); + }, [debouncedQuery()]); + + return ( + setIsOpened(false), + results, + isLoading, + isOpened, + setQuery, + setIsOpened, + setIsLoading, + setResults, + }} + > + + + + ); +} diff --git a/app/hooks/useDebounce.tsx b/app/hooks/useDebounce.tsx new file mode 100644 index 0000000..78c4578 --- /dev/null +++ b/app/hooks/useDebounce.tsx @@ -0,0 +1,23 @@ +import type { Accessor, Setter } from "solid-js"; + +import { createEffect, createSignal } from "solid-js"; + +export function useDebounce( + defaultValue: T, + debounceTime = 300, +): [Accessor, Setter] { + const [debouncedValue, setDebouncedValue] = createSignal(defaultValue); + const [value, setValue] = createSignal(defaultValue); + + createEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value()); + }, debounceTime); + + return () => { + clearTimeout(handler); + }; + }, [value()]); + + return [debouncedValue, setValue]; +} diff --git a/app/layouts/LayoutDefault.tsx b/app/layouts/LayoutDefault.tsx index 3730983..e38479f 100755 --- a/app/layouts/LayoutDefault.tsx +++ b/app/layouts/LayoutDefault.tsx @@ -3,7 +3,8 @@ import type { JSX, JSXElement } from "solid-js"; // import { CookiesContainer } from "@/components/common/Cookies"; import { MobileNavigation } from "@/partials/MobileNavigation"; import { usePageContext } from "vike-solid/usePageContext"; -// import { clientOnly } from "vike-react/clientOnly"; +// import { clientOnly } from "vike-solid/clientOnly"; +import { Search } from "@/components/Search"; import { createEffect, createSignal } from "solid-js"; import { HeroSection } from "@/partials/HeroSection"; import { Navigation } from "@/partials/Navigation"; @@ -15,11 +16,9 @@ import clsx from "clsx"; import "./tailwind.css"; -// const Search = clientOnly(() => import("@/components/Search").then((module) => module.Search)); - -function Search() { - return null; -} +// const Search = clientOnly(() => +// import("@/components/Search").then((module) => module.Search), +// ); function GitHubIcon(props: JSX.IntrinsicElements["svg"]) { return ( @@ -63,7 +62,11 @@ function Header() {
    - {/* } /> */} + + // } + />