N-FIN-30: rework auto complete input (#36)

Resolves #30
This commit is contained in:
Markus Thielker 2024-03-16 20:40:44 +01:00 committed by GitHub
commit c40834f92b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 129 additions and 176 deletions

View file

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React, { useState } from 'react';
import React, { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import CurrencyInput from '@/components/ui/currency-input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
value: Payment | undefined,
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
const router = useRouter();
const [filter, setFilter] = useState<string>('');
const [payorOpen, setPayorOpen] = useState(false);
const [payeeOpen, setPayeeOpen] = useState(false);
const [categoryOpen, setCategoryOpen] = useState(false);
const form = useForm<z.infer<typeof paymentFormSchema>>({
resolver: zodResolver(paymentFormSchema),
defaultValues: {
@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
};
}) ?? [];
const payeeRef = useRef<HTMLInputElement>(null);
const categoryRef = useRef<HTMLInputElement>(null);
const noteRef = useRef<HTMLInputElement>(null);
return (
<Form {...form}>
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
@ -145,61 +143,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payor</FormLabel>
<Popover open={payorOpen} onOpenChange={(open) => {
setPayorOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-64">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayorOpen(false);
}}>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl>
<AutoCompleteInput
placeholder="Select payor"
items={entitiesMapped}
next={payeeRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
@ -211,62 +161,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payee</FormLabel>
<Popover open={payeeOpen} onOpenChange={(open) => {
setPayeeOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayeeOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl ref={payeeRef}>
<AutoCompleteInput
placeholder="Select payee"
items={entitiesMapped}
next={categoryRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
@ -278,62 +179,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Popover open={categoryOpen} onOpenChange={(open) => {
setCategoryOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? categoriesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{categoriesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setCategoryOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormControl ref={categoryRef}>
<AutoCompleteInput
placeholder="Select category"
items={categoriesMapped}
next={noteRef}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}

View file

@ -0,0 +1,100 @@
'use client';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
export interface AutoCompleteInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
items: { label: string, value: any }[];
next?: React.RefObject<HTMLInputElement>;
}
const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>(
({className, type, ...props}, ref) => {
const [value, setValue] = useState(getInitialValue());
const [open, setOpen] = useState(false);
const [lastKey, setLastKey] = useState('');
const [filteredItems, setFilteredItems] = useState(props.items);
function getInitialValue() {
if (!props.items) {
return '';
}
const item = props.items?.find(item => item.value === props.value);
return item?.label || '';
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
props.onChange?.(undefined as any);
const value = e.target.value;
setFilteredItems(props?.items?.filter((item) => {
return item.label.toLowerCase().includes(value.toLowerCase());
}));
setValue(value);
setOpen(value.length > 0);
}
useEffect(() => {
if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) {
setValue(filteredItems[0].label);
setOpen(false);
props.onChange?.({target: {value: filteredItems[0].value}} as any);
props.next && props.next.current?.focus();
}
}, [filteredItems]);
return (
<div className="relative">
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
value={value}
placeholder={props.placeholder || 'Search...'}
onChange={handleChange}
onKeyDown={(e) => {
if (e.metaKey || e.ctrlKey || e.altKey) {
props.onKeyDown?.(e);
return;
}
setLastKey(e.key);
props.onKeyDown?.(e);
}}
/>
{
open && (
<div
className="z-50 bg-background rounded-md border border-border absolute inset-x-0 top-12 max-h-44 overflow-scroll">
{filteredItems?.map((item) =>
<div
className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium"
onClick={() => {
props.onChange?.({target: {value: item.value}} as any);
props.next && props.next.current?.focus();
setValue(item.label);
setOpen(false);
}}
key={item.value}>
{item.label}
</div>,
)}
</div>
)
}
</div>
);
},
);
AutoCompleteInput.displayName = 'Input';
export { AutoCompleteInput };

View file

@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
<Input
placeholder={props.placeholder}
type="text"
inputMode="numeric"
{...field}
onChange={(ev) => {
setValue(ev.target.value);