Custom Auto-Complete Component with Dark Mode Support using Shadcn UI
This page showcases a custom auto-complete component built using the Shadcn UI library for React. The component supports both light and dark modes, offering a sleek and responsive search experience. It’s designed for easy integration, with customizable options like placeholder text, loading states, and empty messages.
Integrate the Component
Step1.
Create a file src/somponents/ui/autocomplete.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import {
CommandGroup,
CommandItem,
CommandList,
CommandInput,
} from "@/components/ui/command";
import { Command as CommandPrimitive } from "cmdk";
import { useState, useRef, useCallback, type KeyboardEvent } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export type Option = Record<"value" | "label", string> & Record<string, string>;
type AutoCompleteProps = {
options: Option[];
emptyMessage: string;
value?: Option;
onValueChange?: (value: Option) => void;
isLoading?: boolean;
disabled?: boolean;
placeholder?: string;
className?: string;
};
export const AutoComplete = ({
options,
placeholder,
emptyMessage,
value,
onValueChange,
disabled,
isLoading = false,
className
}: AutoCompleteProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isOpen, setOpen] = useState(false);
const [selected, setSelected] = useState<Option>(value as Option);
const [inputValue, setInputValue] = useState<string>(value?.label || "");
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (!input) {
return;
}
if (!isOpen) {
setOpen(true);
}
if (event.key === "Enter" && input.value !== "") {
const optionToSelect = options.find(
(option) => option.label === input.value
);
if (optionToSelect) {
setSelected(optionToSelect);
onValueChange?.(optionToSelect);
}
}
if (event.key === "Escape") {
input.blur();
}
},
[isOpen, options, onValueChange]
);
const handleBlur = useCallback(() => {
setOpen(false);
setInputValue(selected?.label);
}, [selected]);
const handleSelectOption = useCallback(
(selectedOption: Option) => {
setInputValue(selectedOption.label);
setSelected(selectedOption);
onValueChange?.(selectedOption);
setTimeout(() => {
inputRef?.current?.blur();
}, 0);
},
[onValueChange]
);
return (
<CommandPrimitive onKeyDown={handleKeyDown}>
<div className={`border rounded-md ${className}`}>
<CommandInput
ref={inputRef}
value={inputValue}
onValueChange={isLoading ? undefined : setInputValue}
onBlur={handleBlur}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
/>
</div>
<div className="relative mt-1">
<div
className={cn(
"animate-in fade-in-0 zoom-in-95 absolute top-0 z-10 w-full rounded-xl bg-white dark:bg-gray-800 outline-none",
isOpen ? "block" : "hidden",className
)}
>
<CommandList className="rounded-lg ring-1 ring-slate-200 dark:ring-gray-700">
{isLoading ? (
<CommandPrimitive.Loading>
<div className="p-1">
<Skeleton className="h-8 w-full" />
</div>
</CommandPrimitive.Loading>
) : null}
{options.length > 0 && !isLoading ? (
<CommandGroup>
{options.map((option) => {
const isSelected = selected?.value === option.value;
return (
<CommandItem
key={option.value}
value={option.label}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onSelect={() => handleSelectOption(option)}
className={cn(
"flex w-full items-center gap-2 dark:text-white",
!isSelected ? "pl-8" : null
)}
>
{isSelected ? <Check className="w-4" /> : null}
{option.label}
</CommandItem>
);
})}
</CommandGroup>
) : null}
{!isLoading ? (
<CommandPrimitive.Empty className="select-none rounded-sm px-2 py-3 text-center text-sm dark:text-gray-400">
{emptyMessage}
</CommandPrimitive.Empty>
) : null}
</CommandList>
</div>
</div>
</CommandPrimitive>
);
};
Step2.
Import and use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { AutoComplete, Option } from "@/components/ui/autocomplete";
export default function Page() {
const [isLoading, setLoading] = React.useState(false);
const [isDisabled, setDisbled] = React.useState(false);
const [value, setValue] = React.useState<Option>();
const FRAMEWORKS = [
{
value: "next.js",
label: "Next.js",
},
{
value: "sveltekit",
label: "SvelteKit",
},
{
value: "nuxt.js",
label: "Nuxt.js",
},
{
value: "remix",
label: "Remix",
},
{
value: "astro",
label: "Astro",
},
{
value: "wordpress",
label: "WordPress",
},
{
value: "express.js",
label: "Express.js",
},
{
value: "nest.js",
label: "Nest.js",
},
];
return (
<>
<div className="not-prose mt-8 flex flex-col gap-4">
<div className="flex items-center gap-2">
<Button variant={"outline"} onClick={() => setLoading((prev) => !prev)}>
Toggle loading
</Button>
<Button variant={"outline"} onClick={() => setDisbled((prev) => !prev)}>
Toggle disabled
</Button>
</div>
<AutoComplete options={FRAMEWORKS}
emptyMessage="No resulsts."
placeholder="Find something"
isLoading={isLoading}
onValueChange={setValue}
value={value}
disabled={isDisabled} />
<span className="text-sm">Current value: {value ? value?.label : "No value selected"}</span>
<span className="text-sm">Loading state: {isLoading ? "true" : "false"}</span>
<span className="text-sm">Disabled: {isDisabled ? "true" : "false"}</span>
</div>
</>
);
}