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
232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|