Skip to content

Commit

Permalink
Merge pull request #2025 from zeitgeistpm/ux-search
Browse files Browse the repository at this point in the history
Improve search bar UX
  • Loading branch information
robhyrk authored Dec 1, 2023
2 parents 89c52d9 + 75b0aeb commit 2b92de4
Show file tree
Hide file tree
Showing 3 changed files with 299 additions and 73 deletions.
216 changes: 155 additions & 61 deletions components/markets/MarketSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

const { data: markets } = useMarketSearch(searchTerm);
const { data: markets, isFetching } = useMarketSearch(searchTerm);

useEffect(() => {
const handleClickOutside = (event) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
Expand All @@ -24,83 +40,161 @@ const MarketSearch = () => {
};
}, [wrapperRef]);

const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selectedRef = useRef<HTMLAnchorElement>(null);

const onKeyDownHandler: KeyboardEventHandler<HTMLInputElement> = (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 (
<div className="mx-3 w-full md:mx-7" ref={wrapperRef}>
<Link href={"/search"} className="w-2 lg:hidden">
<Search className="mr-4 text-ztg-blue" />
</Link>
<div className="hidden items-center lg:flex">
<button
onClick={() => {
setShowSearch(true);
setTimeout(() => {
inputRef.current?.focus();
});
}}
<div
className={`relative w-full overflow-hidden transition-all ${
showSearch ? "max-w-[500px] px-3" : "max-w-[0px]"
}`}
>
<Search className="mr-4 text-ztg-blue" />
</button>
{showSearch && (
<>
<input
ref={inputRef}
className="h-8 w-full max-w-[500px] rounded-sm bg-sky-900 px-2 text-white focus:outline-none "
value={searchTerm}
placeholder="Search markets"
onChange={(event) => {
<input
ref={inputRef}
className={`h-10 w-full rounded-lg bg-sky-900 px-3 text-white outline-none transition-all`}
value={searchTerm}
placeholder="Search markets"
onChange={(event) => {
setShowResults(true);
setSearchTerm(event.target.value);
}}
onKeyDown={onKeyDownHandler}
onFocus={() => {
if (searchTerm?.length > 0) {
setShowResults(true);
setSearchTerm(event.target.value);
}}
onFocus={() => {
setShowResults(true);
}}
}
}}
/>

<div className="absolute right-12 top-[50%] translate-y-[-50%]">
<TypingIndicator
disabled={selectedIndex !== null}
inputRef={inputRef}
isFetching={isFetching}
/>
</div>

{showSearch && (
<button
className="relative right-6 text-sky-600"
className="absolute right-6 top-[50%] translate-y-[-50%] text-sky-600"
onClick={() => {
setSearchTerm("");
inputRef.current?.focus();
if (showResults) {
setShowResults(false);
}
setTimeout(() => {
inputRef.current?.focus();
}, 66);
}}
>
<X size={16} />
<FaDeleteLeft size={16} />
</button>
</>
)}
</div>
{showResults && markets && (
<div className="absolute top-[45px] hidden max-h-[300px] w-[500px] translate-x-[40px] flex-col overflow-scroll rounded-md bg-white py-2 shadow-2xl lg:flex">
<div className="mx-4 text-sky-600">Results</div>
)}
</div>

{markets.length > 0 ? (
markets?.map((market) => (
<Link
href={`/markets/${market.marketId}`}
className="flex justify-between overflow-ellipsis px-4 py-2 hover:bg-sky-100"
onClick={() => {
setShowResults(false);
}}
>
<div className="line-clamp-1 w-85% overflow-ellipsis">
{market.question}
</div>
<div
className={`w-16 rounded-md px-2 py-1 text-center text-xs text-white ${
market.status === MarketStatus.Active
? "bg-sheen-green"
: "bg-vermilion"
}`}
<button
onClick={(e) => {
e.stopPropagation();
setShowSearch(!showSearch);
setTimeout(() => {
if (!showSearch) {
inputRef.current?.focus();
}
});
}}
>
<Search className="mr-4 text-ztg-blue" />
</button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 :scale-95"
show={Boolean(showResults && showSearch && markets)}
>
<div className=" absolute top-[45px] hidden max-h-[420px] w-[500px] flex-col rounded-md bg-white px-2 py-4 shadow-2xl lg:flex">
<div className="subtle-scroll-bar overflow-y-scroll">
{markets?.length ? (
markets?.map((market, index) => (
<Link
key={market.marketId}
href={`/markets/${market.marketId}`}
className={`flex justify-between overflow-ellipsis rounded-md px-4 py-2
${selectedIndex === index && "bg-sky-100"}
${selectedIndex === null && "hover:bg-sky-100"}
`}
onClick={() => {
setShowResults(false);
}}
ref={selectedIndex === index ? selectedRef : undefined}
>
{market.status === MarketStatus.Active
? "Active"
: "Inactive"}
</div>
</Link>
))
) : (
<div className="w-full pb-4 pt-6 text-center">No results</div>
)}
<div className="line-clamp-1 w-85% overflow-ellipsis">
{market.question}
</div>
<div
className={`w-16 rounded-md px-2 py-1 text-center text-xs text-white ${
market.status === MarketStatus.Active
? "bg-green-400"
: "bg-gray-400"
}`}
>
{market.status === MarketStatus.Active
? "Active"
: "Inactive"}
</div>
</Link>
))
) : (
<div className="w-full pb-4 pt-6 text-center">No results</div>
)}
</div>
</div>
)}
</Transition>
</div>
);
};
Expand Down
120 changes: 120 additions & 0 deletions components/ui/TypingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>;
isFetching: boolean;
disabled?: boolean;
}) => {
const [index, setIndex] = useState(0);
const [isTyping, setIsTyping] = useState(false);
const [scope, animate] = useAnimate();

const typingTimer = useRef<NodeJS.Timeout>();

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<Promise<void> | 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 (
<div
ref={scope}
className={`flex items-center justify-center gap-1 transition-opacity ${
isTyping && !disabled ? "opacity-100" : "opacity-0"
}`}
>
<motion.div className="h-1 w-1 rounded-full bg-gray-500"></motion.div>
<motion.div className="h-1 w-1 rounded-full bg-gray-500"></motion.div>
<motion.div className="h-1 w-1 rounded-full bg-gray-500"></motion.div>
</div>
);
};
Loading

0 comments on commit 2b92de4

Please sign in to comment.