Search

Ctrl + K

Autocomplete

This document outlines the steps to create Autocomplete component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • 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.
Step 1: Create useClickAway hook
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
Step 2: Create Autocomplete component
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