| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| "use client"; |
| |
| import { useDebounce } from "@/shared/hooks/useDebounce"; |
| import * as React from "react"; |
| import { cn } from "@/lib/utils"; |
| |
| export type ProjectAutocompletePick = { |
| id: string; |
| originated_id: string; |
| title: string; |
| }; |
| |
| export type ProjectAutocompleteProps = { |
| value: ProjectAutocompletePick | null; |
| onChange: (next: ProjectAutocompletePick | null) => void; |
| search: (q: string) => Promise<ProjectAutocompletePick[]>; |
| inputId?: string; |
| placeholder?: string; |
| ariaLabel?: string; |
| className?: string; |
| }; |
| |
| export function ProjectAutocomplete({ |
| value, |
| onChange, |
| search, |
| inputId, |
| placeholder = "Search projects by id or title…", |
| ariaLabel, |
| className, |
| }: ProjectAutocompleteProps) { |
| const [query, setQuery] = React.useState(value?.originated_id ?? ""); |
| const debounced = useDebounce(query, 200); |
| const [results, setResults] = React.useState<ProjectAutocompletePick[]>([]); |
| const [isFetching, setIsFetching] = React.useState(false); |
| const [open, setOpen] = React.useState(false); |
| const [activeIndex, setActiveIndex] = React.useState(0); |
| const listboxId = React.useId(); |
| |
| React.useEffect(() => { |
| let cancelled = false; |
| if (!debounced.trim()) { |
| setResults([]); |
| return () => { |
| cancelled = true; |
| }; |
| } |
| setIsFetching(true); |
| search(debounced) |
| .then((rows) => { |
| if (!cancelled) setResults(rows); |
| }) |
| .catch(() => { |
| if (!cancelled) setResults([]); |
| }) |
| .finally(() => { |
| if (!cancelled) setIsFetching(false); |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, [debounced, search]); |
| |
| function commit(pick: ProjectAutocompletePick) { |
| onChange(pick); |
| setQuery(pick.originated_id); |
| setOpen(false); |
| } |
| |
| function clear() { |
| onChange(null); |
| setQuery(""); |
| setOpen(false); |
| } |
| |
| return ( |
| <div className={cn("relative", className)}> |
| <input |
| id={inputId} |
| type="text" |
| role="combobox" |
| aria-expanded={open && results.length > 0} |
| aria-controls={listboxId} |
| aria-autocomplete="list" |
| aria-label={ariaLabel} |
| className="w-56 rounded-md border bg-background px-2 py-1 text-xs" |
| placeholder={placeholder} |
| value={query} |
| onChange={(e) => { |
| setQuery(e.currentTarget.value); |
| setOpen(true); |
| if (value) onChange(null); |
| }} |
| onFocus={() => setOpen(true)} |
| onBlur={() => setTimeout(() => setOpen(false), 100)} |
| onKeyDown={(e) => { |
| if (e.key === "ArrowDown") { |
| e.preventDefault(); |
| setActiveIndex((i) => Math.min(i + 1, results.length - 1)); |
| setOpen(true); |
| } else if (e.key === "ArrowUp") { |
| e.preventDefault(); |
| setActiveIndex((i) => Math.max(i - 1, 0)); |
| } else if (e.key === "Enter") { |
| const pick = results[activeIndex]; |
| if (pick) { |
| e.preventDefault(); |
| commit(pick); |
| } |
| } else if (e.key === "Escape") { |
| setOpen(false); |
| } |
| }} |
| /> |
| {value ? ( |
| <button |
| type="button" |
| aria-label="Clear selection" |
| className="-translate-y-1/2 absolute top-1/2 right-1 rounded px-1 text-muted-foreground text-xs hover:text-foreground" |
| onMouseDown={(e) => e.preventDefault()} |
| onClick={clear} |
| > |
| × |
| </button> |
| ) : null} |
| {open && (results.length > 0 || isFetching) ? ( |
| // ARIA combobox listbox — use div+button so biome's "non-interactive |
| // element with interactive role" lint stays clean while the WAI-ARIA |
| // contract (role=listbox / option) is preserved. |
| <div |
| id={listboxId} |
| role="listbox" |
| tabIndex={-1} |
| className="absolute z-20 mt-1 max-h-60 w-72 overflow-auto rounded-md border bg-popover py-1 text-xs shadow-md" |
| > |
| {isFetching && results.length === 0 ? ( |
| <p className="px-3 py-2 text-muted-foreground">Searching…</p> |
| ) : null} |
| {results.map((p, i) => ( |
| <button |
| key={p.id} |
| type="button" |
| role="option" |
| aria-selected={i === activeIndex} |
| className={cn( |
| "block w-full cursor-pointer px-3 py-2 text-left", |
| i === activeIndex ? "bg-muted" : "hover:bg-muted/60", |
| )} |
| onMouseDown={(e) => { |
| e.preventDefault(); |
| commit(p); |
| }} |
| > |
| <span className="block font-mono">{p.originated_id}</span> |
| <span className="block text-muted-foreground">{p.title}</span> |
| </button> |
| ))} |
| </div> |
| ) : null} |
| </div> |
| ); |
| } |