Untitled
unknown
plain_text
10 months ago
8.8 kB
13
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