Untitled

 avatar
unknown
plain_text
10 months ago
8.8 kB
11
Indexable
"use client";

import { useState, useEffect, useRef, useCallback } from "react";

export type Option = {
  id: number;
  name?: string;               // Hasta adı (tercih edilen)
  national_id?: string | null; // TC Kimlik No
  label?: string;              // Eski kullanım (geriye dönük uyum)
};

type Props = {
  label: string;
  placeholder?: string;
  onSearch: (query: string, opts?: { signal?: AbortSignal }) => Promise<Option[]>;
  onSelect: (option: Option | null) => void;
  value?: Option | null;
  error?: string;
  allowClear?: boolean; // seçimi temizleme butonu
};

export default function AutocompleteInput({
  label,
  placeholder,
  onSearch,
  onSelect,
  value,
  error,
  allowClear = true,
}: Props) {
  // ---- helpers --------------------------------------------------------------
  const makeDisplayText = (o: Option | null) => {
    if (!o) return "";
    if (o.name) {
      const tc = o.national_id ?? "—";
      return `${o.name} — TC: ${tc}`;
    }
    return o.label ?? "";
  };

  const renderPrimary = (o: Option) => o.name ?? o.label ?? "";
  const renderSecondary = (o: Option) => {
    if (!o.name && !o.national_id) return "";
    const tc = o.national_id ?? "—";
    return o.name ? `TC: ${tc}` : "";
  };

  // ---- state ---------------------------------------------------------------
  const [query, setQuery] = useState(makeDisplayText(value ?? null));
  const [options, setOptions] = useState<Option[]>([]);
  const [showOptions, setShowOptions] = useState(false);
  const [loading, setLoading] = useState(false);
  const [highlighted, setHighlighted] = useState(0);

  const containerRef = useRef<HTMLDivElement>(null);
  const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const reqSeq = useRef(0);

  useEffect(() => {
    setQuery(makeDisplayText(value ?? null));
  }, [value]);

  // ---- fetch with debounce & abort ----------------------------------------
  const fetchOptions = useCallback(
    async (val: string) => {
      const trimmed = val.trim();
      if (trimmed.length < 2) {
        setOptions([]);
        setShowOptions(false);
        setLoading(false);
        return;
      }
      if (abortRef.current) abortRef.current.abort();
      abortRef.current = new AbortController();

      const mySeq = ++reqSeq.current;
      setLoading(true);
      try {
        const res = await onSearch(trimmed, { signal: abortRef.current.signal });
        if (mySeq === reqSeq.current) {
          setOptions(res);
          setShowOptions(true);
          setHighlighted(0);
        }
      } catch {
        // sessizce düş
      } finally {
        if (mySeq === reqSeq.current) setLoading(false);
      }
    },
    [onSearch]
  );

  const handleChange = (val: string) => {
    setQuery(val);
    if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
    debounceTimeout.current = setTimeout(() => fetchOptions(val), 300);
  };

  useEffect(() => {
    return () => {
      if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
      if (abortRef.current) abortRef.current.abort();
    };
  }, []);

  // ---- selection / clear ---------------------------------------------------
  const handleSelect = (opt: Option) => {
    onSelect(opt);
    setQuery(makeDisplayText(opt));
    setShowOptions(false);
  };

  const handleClear = () => {
    onSelect(null);
    setQuery("");
    setOptions([]);
    setShowOptions(false);
  };

  // ---- keyboard nav --------------------------------------------------------
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (!showOptions) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      if (options.length > 0) setHighlighted((p) => (p + 1) % options.length);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      if (options.length > 0) setHighlighted((p) => (p - 1 + options.length) % options.length);
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (options.length > 0) handleSelect(options[highlighted]);
    } else if (e.key === "Escape") {
      setShowOptions(false);
    }
  };

  // ---- click outside -------------------------------------------------------
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setShowOptions(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  // ---- a11y ids ------------------------------------------------------------
  const inputId = useRef(`ac-input-${Math.random().toString(36).slice(2)}`).current;
  const listId = useRef(`ac-list-${Math.random().toString(36).slice(2)}`).current;

  const minQueryReached = query.trim().length >= 2;

  return (
    <div className="relative" ref={containerRef}>
      <label className="block text-sm font-medium mb-1" htmlFor={inputId}>
        {label}
      </label>

      <div className="relative">
        <input
          id={inputId}
          type="text"
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={showOptions}
          aria-controls={listId}
          aria-activedescendant={
            showOptions && options.length > 0 ? `${listId}-opt-${highlighted}` : undefined
          }
          value={query}
          placeholder={placeholder}
          onChange={(e) => handleChange(e.target.value)}
          onFocus={() => setShowOptions(options.length > 0)}
          onKeyDown={handleKeyDown}
          className={`h-11 w-full rounded-xl px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500 border ${
            error ? "border-red-500" : "border-gray-300 dark:border-neutral-700"
          }`}
          style={{ background: "var(--bg-light)", color: "var(--text-primary)" }}
        />

        {allowClear && !!query && (
          <button
            type="button"
            onClick={handleClear}
            className="absolute right-2 top-1/2 -translate-y-1/2 text-xs opacity-70 hover:opacity-100"
            aria-label="Seçimi temizle"
          >
            ✕
          </button>
        )}
      </div>

      {error && (
        <div className="mt-1 inline-flex items-center rounded-lg px-2 py-1 text-xs bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
          {error}
        </div>
      )}

      {showOptions && (
        <ul
          id={listId}
          role="listbox"
          className="
            absolute z-50 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border shadow
            bg-[color:var(--bg-light)] dark:bg-[color:var(--bg)]
            border-[color:var(--border-dark)]
          "
        >
          {loading && options.length === 0 && (
            <li className="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">Aranıyor…</li>
          )}

          {!loading && options.length === 0 && minQueryReached && (
            <li className="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">Sonuç bulunamadı</li>
          )}

          {options.map((opt, idx) => {
            const isActive = highlighted === idx;
            const primary = renderPrimary(opt);
            const secondary = renderSecondary(opt);

            return (
              <li
                id={`${listId}-opt-${idx}`}
                role="option"
                aria-selected={isActive}
                key={opt.id}
                onMouseDown={(e) => {
                  e.preventDefault(); // input blur olmadan seçim
                  handleSelect(opt);
                }}
                className={`
                  px-3 py-2 cursor-pointer
                  text-[color:var(--text-primary)]
                  hover:bg-black/5 dark:hover:bg-white/10
                  ${isActive ? "bg-[color:var(--accent)]/15 dark:bg-[color:var(--accent)]/25" : ""}
                `}
              >
                <div className="font-medium leading-5">{primary}</div>
                {secondary && (
                  <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
                    {secondary}
                  </div>
                )}
              </li>
            );
          })}
        </ul>
      )}

      {loading && options.length > 0 && (
        <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">Aranıyor…</div>
      )}
    </div>
  );
}
Editor is loading...
Leave a Comment