import { useState, useRef, useEffect, useCallback } from "react"; // ============================================= // Mock Data & API (DO NOT MODIFY) // ============================================= const FRUITS = [ "Apple", "Apricot", "Avocado", "Banana", "Blackberry", "Blueberry", "Cherry", "Coconut", "Cranberry", "Dragon Fruit", "Fig", "Grape", "Grapefruit", "Guava", "Kiwi", "Lemon", "Lime", "Lychee", "Mango", "Melon", "Nectarine", "Orange", "Papaya", "Passion Fruit", "Peach", "Pear", "Pineapple", "Plum", "Pomegranate", "Raspberry", "Strawberry", "Tangerine", "Watermelon", ]; function mockSearchApi(query: string): Promise<string[]> { return new Promise((resolve) => { setTimeout(() => { if (!query.trim()) return resolve([]); const results = FRUITS.filter((fruit) => fruit.toLowerCase().includes(query.toLowerCase()) ); resolve(results); }, 300); }); } // ============================================= // TODO: Implement a debounce function // ============================================= // // Signature: // function debounce<T extends (...args: any[]) => void>( // fn: T, // delay: number // ): (...args: Parameters<T>) => void // // Hints: // - Use setTimeout / clearTimeout // - Store the timer ID in a closure variable // - Return a wrapper that clears the old timer and sets a new one function debounce(fn: (...args: any[]) => void, delay: number) { // TODO: Your implementation here // For now it just calls fn immediately (no debouncing) return (...args: any[]) => { fn(...args); }; } // ============================================= // TODO: Implement a HighlightMatch component // ============================================= // // Takes { text, query } props. // Splits `text` by the `query` (case-insensitive) and wraps // matching parts in a <mark> tag with a highlight style. // // Hints: // - Escape special regex chars in query // - Use text.split(new RegExp(`(${escaped})`, "gi")) // - Check regex.test(part) to decide if it's a match function HighlightMatch({ text, query }: { text: string; query: string }) { // TODO: Replace with highlighted version return <span>{text}</span>; } // ============================================= // TODO: Build the Autocomplete Component // ============================================= // // Requirements: // 1. Debounce API calls as the user types (300ms) // 2. Show a dropdown list of matching suggestions // 3. Allow selecting a suggestion by clicking // 4. Keyboard navigation: ArrowUp, ArrowDown, Enter, Escape // 5. Highlight matching text in each suggestion // 6. Show a loading spinner while fetching // 7. Close dropdown on outside click // 8. Show "No results found" for empty results // // State you'll need: // - query: string (input value) // - suggestions: string[] (API results) // - isOpen: boolean (dropdown visibility) // - isLoading: boolean (fetching state) // - focusedIndex: number (keyboard-highlighted item, -1 = none) // // Refs you'll need: // - wrapperRef: for outside click detection // - listRef: for scrolling focused item into view export default function App() { const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState<string[]>([]); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); // TODO: Create refs // const wrapperRef = useRef<HTMLDivElement>(null); // ============================================= // TODO: Outside click detection // ============================================= // useEffect(() => { // const handleClickOutside = (e: MouseEvent) => { // if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { // setIsOpen(false); // } // }; // document.addEventListener("mousedown", handleClickOutside); // return () => document.removeEventListener("mousedown", handleClickOutside); // }, []); // ============================================= // TODO: Fetch suggestions (debounced) // ============================================= // Should: // - Set isLoading to true // - Call mockSearchApi(value) // - Update suggestions state // - Open/close dropdown based on results // - Set isLoading to false // // Wrap with your debounce function (300ms) // Use useCallback to keep the reference stable const fetchSuggestions = async (value: string) => { // TODO: Your code here }; // const debouncedFetch = useCallback(debounce(fetchSuggestions, 300), []); // ============================================= // TODO: Handle input change // ============================================= // Should: // - Update query state immediately // - Show loading state // - Call debouncedFetch with the new value const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); // TODO: trigger debounced fetch }; // ============================================= // TODO: Handle suggestion selection // ============================================= const handleSelect = (value: string) => { // TODO: Set query, close dropdown, clear suggestions }; // ============================================= // TODO: Keyboard navigation // ============================================= // Handle: // ArrowDown → move focusedIndex forward (wrap) // ArrowUp → move focusedIndex backward (wrap) // Enter → select focused suggestion // Escape → close dropdown const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { // TODO: Your code here }; // Reset focusedIndex when suggestions change useEffect(() => { setFocusedIndex(-1); }, [suggestions]); return ( <div style={{ maxWidth: 500, margin: "0 auto", padding: 24, fontFamily: "system-ui" }}> <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 4 }}> Autocomplete / Typeahead </h2> <p style={{ fontSize: 14, color: "#666", marginBottom: 16 }}> Build an autocomplete that searches fruits with debouncing, keyboard navigation, and text highlighting. </p> {/* Available keywords */} <div style={{ marginBottom: 20, padding: 14, background: "#f9fafb", border: "1px solid #e5e7eb", borderRadius: 8, }}> <p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: "#555" }}> Available keywords: </p> <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}> {FRUITS.map((fruit) => ( <span key={fruit} style={{ padding: "2px 8px", background: "#fff", border: "1px solid #e5e7eb", borderRadius: 4, fontSize: 11, color: "#666", }} > {fruit} </span> ))} </div> </div> {/* ============================================= */} {/* TODO: Autocomplete input + dropdown */} {/* ============================================= */} {/* Wrap in a div with wrapperRef for outside click */} <div style={{ position: "relative", marginBottom: 20 }}> <label style={{ display: "block", fontSize: 14, fontWeight: 500, marginBottom: 8 }}> Search Fruits </label> <input type="text" value={query} onChange={handleChange} onKeyDown={handleKeyDown} placeholder="Start typing to search..." autoComplete="off" role="combobox" aria-expanded={isOpen} aria-autocomplete="list" style={{ width: "100%", padding: "10px 14px", border: "1px solid #d1d5db", borderRadius: 8, fontSize: 14, outline: "none", boxSizing: "border-box", }} /> {/* =========================================== */} {/* TODO: Loading state */} {/* =========================================== */} {/* Show a "Searching..." indicator when isLoading */} {/* =========================================== */} {/* TODO: Suggestions dropdown */} {/* =========================================== */} {/* - Only show when isOpen && !isLoading - Use role="listbox" on the <ul> - Each <li> has role="option" - Highlight the focused item (focusedIndex) - Use <HighlightMatch> to render each suggestion - onClick → handleSelect(item) - Show "No results found" if suggestions is empty and query is not blank */} </div> {/* Hint */} <div style={{ marginTop: 24, padding: 16, background: "#f9fafb", borderLeft: "4px solid #111", borderRadius: "0 8px 8px 0", fontSize: 13, color: "#555", }}> <strong>Steps:</strong> (1) Wire up debounced fetch on input change. (2) Render the dropdown with suggestions. (3) Add keyboard nav. (4) Implement HighlightMatch. (5) Add outside click to close. (6) Add loading spinner. </div> </div> ); }