diff --git a/components/markets/MarketSearch.tsx b/components/markets/MarketSearch.tsx index f1f6acc96..2ef297faa 100644 --- a/components/markets/MarketSearch.tsx +++ b/components/markets/MarketSearch.tsx @@ -1,17 +1,33 @@ +import { Transition } from "@headlessui/react"; import { MarketStatus } from "@zeitgeistpm/indexer"; import { useMarketSearch } from "lib/hooks/queries/useMarketSearch"; import Link from "next/link"; -import { useEffect, useRef, useState } from "react"; +import { FaDeleteLeft } from "react-icons/fa6"; +import { + Fragment, + KeyboardEventHandler, + RefObject, + use, + useEffect, + useRef, + useState, +} from "react"; import { Search, X } from "react-feather"; +import { AnimatePresence, Variants, motion, useAnimate } from "framer-motion"; +import { TAILWIND } from "lib/constants"; +import { TypingIndicator } from "components/ui/TypingIndicator"; +import { useRouter } from "next/router"; const MarketSearch = () => { + const router = useRouter(); const [searchTerm, setSearchTerm] = useState(""); const [showResults, setShowResults] = useState(false); const [showSearch, setShowSearch] = useState(false); const wrapperRef = useRef(null); const inputRef = useRef(null); - const { data: markets } = useMarketSearch(searchTerm); + const { data: markets, isFetching } = useMarketSearch(searchTerm); + useEffect(() => { const handleClickOutside = (event) => { if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { @@ -24,83 +40,161 @@ const MarketSearch = () => { }; }, [wrapperRef]); + const [selectedIndex, setSelectedIndex] = useState(null); + const selectedRef = useRef(null); + + const onKeyDownHandler: KeyboardEventHandler = (event) => { + if (event.key === "ArrowDown" && markets) { + event.preventDefault(); + if (selectedIndex === null) { + setSelectedIndex(0); + } else if (selectedIndex < markets.length - 1) { + setSelectedIndex(selectedIndex + 1); + } + } else if (event.key === "ArrowUp" && markets) { + event.preventDefault(); + if (selectedIndex === null) { + setSelectedIndex(markets.length - 1); + } else if (selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + } else if (event.key === "Enter" && markets && selectedIndex !== null) { + router.push(`/markets/${markets[selectedIndex].marketId}`); + setTimeout(() => { + setShowResults(false); + setSearchTerm(""); + inputRef.current?.blur(); + }, 100); + } else { + setSelectedIndex(null); + } + }; + + useEffect(() => { + if (selectedIndex !== null) { + selectedRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [selectedIndex]); + return (
- - {showSearch && ( - <> - { + { + setShowResults(true); + setSearchTerm(event.target.value); + }} + onKeyDown={onKeyDownHandler} + onFocus={() => { + if (searchTerm?.length > 0) { setShowResults(true); - setSearchTerm(event.target.value); - }} - onFocus={() => { - setShowResults(true); - }} + } + }} + /> + +
+ +
+ + {showSearch && ( - - )} -
- {showResults && markets && ( -
-
Results
+ )} +
- {markets.length > 0 ? ( - markets?.map((market) => ( - { - setShowResults(false); - }} - > -
- {market.question} -
-
{ + e.stopPropagation(); + setShowSearch(!showSearch); + setTimeout(() => { + if (!showSearch) { + inputRef.current?.focus(); + } + }); + }} + > + + +
+ +
+
+ {markets?.length ? ( + markets?.map((market, index) => ( + { + setShowResults(false); + }} + ref={selectedIndex === index ? selectedRef : undefined} > - {market.status === MarketStatus.Active - ? "Active" - : "Inactive"} -
- - )) - ) : ( -
No results
- )} +
+ {market.question} +
+
+ {market.status === MarketStatus.Active + ? "Active" + : "Inactive"} +
+ + )) + ) : ( +
No results
+ )} +
- )} + ); }; diff --git a/components/ui/TypingIndicator.tsx b/components/ui/TypingIndicator.tsx new file mode 100644 index 000000000..e7bcc1207 --- /dev/null +++ b/components/ui/TypingIndicator.tsx @@ -0,0 +1,120 @@ +import { motion, useAnimate } from "framer-motion"; +import { TAILWIND } from "lib/constants"; +import { RefObject, useEffect, useRef, useState } from "react"; + +export const TypingIndicator = ({ + inputRef, + isFetching, + disabled, +}: { + inputRef: RefObject; + isFetching: boolean; + disabled?: boolean; +}) => { + const [index, setIndex] = useState(0); + const [isTyping, setIsTyping] = useState(false); + const [scope, animate] = useAnimate(); + + const typingTimer = useRef(); + + useEffect(() => { + const listener = () => { + setIndex((prev) => { + if (prev + 1 >= 3) { + return 0; + } + return prev + 1; + }); + setIsTyping(() => true); + clearTimeout(typingTimer.current); + typingTimer.current = setTimeout(() => { + setIsTyping(() => false); + }, 1000); + }; + inputRef.current?.addEventListener("keydown", listener); + return () => { + inputRef.current?.removeEventListener("keydown", listener); + }; + }, [inputRef]); + + const typingAnimation = async () => { + if (disabled) return; + animate( + "div", + { transform: "translateY(0%)", opacity: 0.2 }, + { duration: 0.1 }, + ); + + await animate( + `div:nth-child(${index + 1})`, + { + transform: "translateY(-80%)", + opacity: 0.5, + }, + { duration: 0.1 }, + ); + + await animate( + `div:nth-child(${index + 1})`, + { + transform: "translateY(0%)", + opacity: 0.2, + }, + { duration: 0.1 }, + ); + }; + + const loadingAnimationRef = useRef | null>(null); + + const loadingAnimation = async () => { + if (disabled) return; + animate("div:nth-child(1)", { opacity: 0 }, { duration: 0.1 }); + animate("div:nth-child(3)", { opacity: 0 }, { duration: 0.1 }); + + await animate( + "div:nth-child(2)", + { + opacity: 1, + transform: "scale(2.7)", + backgroundColor: TAILWIND.theme.colors["blue"][500], + }, + { duration: 0.6 }, + ); + await animate( + "div:nth-child(2)", + { + transform: "scale(1)", + opacity: 0.2, + backgroundColor: TAILWIND.theme.colors["gray"][500], + }, + { duration: 0.3 }, + ); + }; + + useEffect(() => { + if (isFetching) { + loadingAnimationRef.current = loadingAnimation(); + } else { + if (loadingAnimationRef.current) { + loadingAnimationRef.current.then(() => { + typingAnimation(); + }); + } else { + typingAnimation(); + } + } + }, [index, isFetching]); + + return ( +
+ + + +
+ ); +}; diff --git a/pages/search.tsx b/pages/search.tsx index b5ac81916..f7eb07b90 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -1,38 +1,50 @@ import { MarketStatus } from "@zeitgeistpm/indexer"; +import { TypingIndicator } from "components/ui/TypingIndicator"; import { useMarketSearch } from "lib/hooks/queries/useMarketSearch"; import { NextPage } from "next"; import Link from "next/link"; -import { useState } from "react"; +import { use, useEffect, useRef, useState } from "react"; import { X } from "react-feather"; +import { FaDeleteLeft } from "react-icons/fa6"; const SearchPage: NextPage = () => { const [searchTerm, setSearchTerm] = useState(""); - const { data: markets } = useMarketSearch(searchTerm); + const { data: markets, isFetching } = useMarketSearch(searchTerm); + const inputRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + inputRef?.current?.focus(); + }, 66); + }, [inputRef]); + return ( -
-
+
+
{ setSearchTerm(event.target.value); }} /> +
+ +
{markets && ( -
-
Results
- +
{markets.length > 0 ? ( markets?.map((market) => ( {
{market.status === MarketStatus.Active