This document outlines the steps to create Autocomplete
component styled withTailwind CSS and using some npm dependency libraries.
Node.js
and npm
installed on your machine.Tailwind CSS
installed in your project.CVA(class-variance-authority)
is a utility for managing CSS class names based on various conditions.clsx
is a tiny utility for constructing className strings conditionally.1// use-click-away.ts
2import { RefObject, useEffect } from "react";
3
4const defaultEvents = ["mousedown", "touchstart"];
5type DefaultEventType = "mousedown" | "touchstart";
6
7const useClickAway = (
8 ref: RefObject<HTMLElement | null>,
9 onClickAway: (event: MouseEvent | TouchEvent) => void,
10) => {
11 useEffect(() => {
12 const handler = (event: MouseEvent | TouchEvent) => {
13 const el = ref.current!;
14 el && !el.contains(event.target as HTMLElement) && onClickAway(event);
15 };
16
17 for (const eventName of defaultEvents) {
18 document.addEventListener(eventName as DefaultEventType, handler);
19 }
20
21 return () => {
22 for (const eventName of defaultEvents) {
23 document.removeEventListener(eventName as DefaultEventType, handler);
24 }
25 };
26 // eslint-disable-next-line react-hooks/exhaustive-deps
27 }, [ref]);
28};
29
30export default useClickAway;
31
1// autocomplete.component.tsx
2import useClickAway from "@/hooks/use-click-away";
3import clsx from "clsx";
4import { useRef, useState } from "react";
5
6const OPTION_HEIGHT = 32;
7
8enum PositionType {
9 Top,
10 Bottom,
11}
12
13interface Props {
14 value: string;
15 placeholder?: string;
16 className?: string;
17 inputClassName?: string;
18 inputWrapperClassName?: string;
19 optionClassName?: string;
20 optionsWrapperClassName?: string;
21 disabled?: boolean;
22 options: string[];
23 onChange: (value: string) => void;
24}
25
26const Autocomplete: React.FC<AutocompleteProps> = ({
27 value,
28 placeholder,
29 className,
30 inputClassName,
31 inputWrapperClassName,
32 optionClassName,
33 optionsWrapperClassName,
34 disabled,
35 options,
36 onChange,
37}) => {
38 const dropdownRef = useRef<HTMLDivElement | null>(null);
39 const scrollRef = useRef<HTMLDivElement | null>(null);
40
41 const [opening, setOpening] = useState<boolean>(false);
42 const [position, setPosition] = useState<PositionType>(PositionType.Top);
43 const [selectedOption, setSelectedOption] = useState<string | null>(null);
44
45 const filteredOptions = value.trim()
46 ? options.filter((item) => item.toLowerCase().includes(value.toLowerCase()))
47 : options;
48
49 const closeDropdown = () => {
50 setOpening(false);
51 setSelectedOption(null);
52 scrollRef.current?.scrollTo(0, 0);
53 };
54
55 useClickAway(dropdownRef, closeDropdown);
56
57 const scrollToSelectedOption = (direction: "up" | "down", index: number) => {
58 const optionHeight = scrollRef.current!.getBoundingClientRect().height;
59 const scrollBottom =
60 scrollRef.current!.scrollHeight -
61 optionHeight -
62 scrollRef.current!.scrollTop;
63
64 if (direction === "up") {
65 if (index === filteredOptions.length - 1) {
66 scrollRef.current!.scrollTo(
67 0,
68 scrollRef.current!.scrollHeight - optionHeight
69 );
70 } else {
71 const optionToTop = index * OPTION_HEIGHT;
72 if (optionToTop < scrollRef.current!.scrollTop) {
73 scrollRef.current!.scrollBy(0, -optionHeight / 2);
74 }
75 }
76 } else {
77 if (index === 0) {
78 scrollRef.current!.scrollTo(0, 0);
79 } else {
80 const optionToBottom =
81 OPTION_HEIGHT * (filteredOptions.length - 1 - index);
82 if (optionToBottom < scrollBottom) {
83 scrollRef.current!.scrollBy(0, optionHeight / 2);
84 }
85 }
86 }
87 };
88
89 const pressKeyDown = () => {
90 const currentIndex = selectedOption
91 ? filteredOptions.indexOf(selectedOption)
92 : -1;
93 const nextIndex = (currentIndex + 1) % filteredOptions.length;
94 setSelectedOption(filteredOptions[nextIndex]);
95 scrollToSelectedOption("down", nextIndex);
96 };
97
98 const pressKeyUp = () => {
99 const currentIndex = selectedOption
100 ? filteredOptions.indexOf(selectedOption)
101 : 1;
102 const nextIndex =
103 (currentIndex - 1 + filteredOptions.length) % filteredOptions.length;
104 setSelectedOption(filteredOptions[nextIndex]);
105 scrollToSelectedOption("up", nextIndex);
106 };
107
108 const handleSelect = (value: string) => {
109 closeDropdown();
110 onChange(value);
111 };
112
113 const handleFocus = () => {
114 const { bottom } = dropdownRef.current!.getBoundingClientRect();
115 const distanceToBottom = window.innerHeight - bottom;
116 setPosition(
117 distanceToBottom > 230 ? PositionType.Bottom : PositionType.Top
118 );
119 setOpening(true);
120 };
121
122 const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
123 if (e.key === "Enter") {
124 handleSelect(selectedOption || "");
125 } else if (e.key === "Escape") {
126 closeDropdown();
127 } else if (e.key === "ArrowDown") {
128 pressKeyDown();
129 } else if (e.key === "ArrowUp") {
130 pressKeyUp();
131 } else {
132 handleFocus();
133 }
134 };
135
136 const handleChange = (value: string) => {
137 onChange(value);
138 };
139
140 const handleHover = (val: string) => {
141 setSelectedOption(val);
142 };
143
144 return (
145 <div ref={dropdownRef} className={clsx("relative w-[12rem]", className)}>
146 <div
147 className={clsx(
148 "flex items-center rounded-md border px-4 py-2",
149 {
150 "border-neutral-300 text-neutral-300 dark:border-neutral-600 dark:text-neutral-700":
151 disabled,
152 "border-neutral-400 text-neutral-600 hover:border-cyan-500 dark:text-neutral-200":
153 !disabled,
154 },
155 { "border-cyan-500": opening },
156 inputWrapperClassName
157 )}
158 >
159 <input
160 type="text"
161 className={clsx(
162 "w-full bg-transparent text-sm focus:outline-none",
163 inputClassName
164 )}
165 disabled={disabled}
166 placeholder={placeholder}
167 value={value}
168 onChange={(e) => handleChange(e.target.value)}
169 onFocus={handleFocus}
170 onKeyDown={handleKeyPress}
171 />
172 </div>
173 <div
174 ref={scrollRef}
175 className={clsx(
176 "absolute left-0 z-10 max-h-[13rem] min-w-full max-w-[calc(100%+2rem)] overflow-auto rounded-sm border border-neutral-200 bg-white dark:border-neutral-600 dark:bg-neutral-700",
177 {
178 "bottom-[calc(100%+0.5rem)]": position === PositionType.Top,
179 "top-[calc(100%+0.5rem)]": position === PositionType.Bottom,
180 },
181 { hidden: !opening || filteredOptions.length === 0 },
182 optionsWrapperClassName
183 )}
184 >
185 {filteredOptions.map((item) => (
186 <p
187 key={item}
188 style={{ height: OPTION_HEIGHT }}
189 className={clsx(
190 "flex cursor-pointer items-center overflow-hidden text-ellipsis whitespace-nowrap px-3 text-sm font-medium",
191 { "bg-neutral-300 dark:bg-neutral-500": selectedOption === item },
192 optionClassName
193 )}
194 onClick={() => handleSelect(item)}
195 onMouseEnter={() => handleHover(item)}
196 >
197 {item}
198 </p>
199 ))}
200 </div>
201 </div>
202 );
203};
204
205export default Autocomplete;
206