RJBULK-APP/components/ui/combobox.tsx
SJCoetz 1efa979443 feat: initial setup of rj bulk supplies backoffice app
Adds complete initial codebase for the RJ Bulk Supplies fuel delivery backoffice:
- Authentication pages and protected route layouts
- Customer, supplier, fuel purchase, and delivery note management
- Custom reporting and customer statement sending tools
- Full UI component library and Tailwind styling
- Server actions, API routes, and PocketBase integration
- Development scripts and project configuration updates
- Updated README and package metadata
2026-07-19 20:38:16 +02:00

232 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ComboboxItem {
value: string;
label: string;
hint?: string;
}
interface ComboboxProps {
items: ComboboxItem[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
emptyMessage?: string;
required?: boolean;
name?: string;
className?: string;
disabled?: boolean;
/** Optional id to forward to the underlying input. */
id?: string;
}
/**
* Lightweight searchable combobox — a text input with a filtered listbox
* beneath it. No portal, no popper positioning, no extra deps. Keyboard
* navigable (↑/↓/Enter/Escape).
*/
export function Combobox({
items,
value,
onChange,
placeholder = "Search…",
emptyMessage = "No matches.",
required,
name,
className,
disabled,
id,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [highlight, setHighlight] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const selected = items.find((it) => it.value === value);
// When a value is set externally and the dropdown is closed, show its label.
React.useEffect(() => {
if (!open && selected) setQuery(selected.label);
}, [open, selected]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items.slice(0, 50);
return items
.filter(
(it) =>
it.label.toLowerCase().includes(q) ||
it.value.toLowerCase().includes(q) ||
(it.hint && it.hint.toLowerCase().includes(q))
)
.slice(0, 50);
}, [items, query]);
// Close on outside click
React.useEffect(() => {
if (!open) return;
function onDocMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open]);
// Reset highlight when the filtered list changes
React.useEffect(() => {
setHighlight(0);
}, [query, open]);
// Scroll highlighted item into view
React.useEffect(() => {
if (!open) return;
const list = listRef.current;
if (!list) return;
const item = list.children[highlight] as HTMLElement | undefined;
if (item) {
const top = item.offsetTop;
const bottom = top + item.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) {
list.scrollTop = bottom - list.clientHeight;
}
}
}, [highlight, open]);
function commit(item: ComboboxItem) {
onChange(item.value);
setQuery(item.label);
setOpen(false);
inputRef.current?.blur();
}
function clear() {
onChange("");
setQuery("");
inputRef.current?.focus();
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setOpen(true);
e.preventDefault();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const it = filtered[highlight];
if (it) commit(it);
} else if (e.key === "Escape") {
setOpen(false);
// Restore display to selected
setQuery(selected?.label || "");
}
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<input
ref={inputRef}
id={id}
type="text"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
aria-controls="combobox-listbox"
autoComplete="off"
required={required}
disabled={disabled}
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
// If the user is editing, clear the value until they pick again
if (selected && e.target.value !== selected.label) {
onChange("");
}
}}
onFocus={() => {
setOpen(true);
// Select all so a re-typing replaces cleanly
requestAnimationFrame(() => inputRef.current?.select());
}}
onKeyDown={onKeyDown}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pr-16 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
{value && (
<button
type="button"
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onClick={clear}
className="px-1.5 text-xs text-muted-foreground hover:text-foreground"
aria-label="Clear selection"
>
×
</button>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
{name && <input type="hidden" name={name} value={value} />}
</div>
{open && (
<ul
ref={listRef}
id="combobox-listbox"
role="listbox"
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-md border bg-popover p-1 text-sm shadow-md"
>
{filtered.length === 0 ? (
<li className="px-3 py-2 text-muted-foreground">{emptyMessage}</li>
) : (
filtered.map((it, i) => {
const active = it.value === value;
return (
<li
key={it.value}
role="option"
aria-selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commit(it)}
onMouseEnter={() => setHighlight(i)}
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5",
i === highlight ? "bg-accent text-accent-foreground" : ""
)}
>
<div className="flex flex-col min-w-0">
<span className="truncate">{it.label}</span>
{it.hint && (
<span className="text-xs text-muted-foreground truncate">{it.hint}</span>
)}
</div>
{active && <Check className="h-4 w-4 shrink-0 text-primary" />}
</li>
);
})
)}
</ul>
)}
</div>
);
}