NextJS14中Shadcn ComboBox+TanStack虚拟列表下拉不显示问题
问题分析与修复方案
问题根源
- 虚拟列表未响应初始状态:
useVirtualizer未监听filteredOptions和Popover的打开状态,导致下拉框首次打开时,虚拟列表无法正确计算并渲染初始选项。 - 滚动容器引用时机错误:
parentRef指向的CommandGroup在Popover未打开时不存在,getScrollElement返回null,虚拟列表无法识别滚动区域,无法渲染选项。 - 表单值同步错误:选中选项时,使用了旧的
field.value而非当前选中值,导致表单状态未正确更新。
修复步骤
1. 让虚拟列表响应状态变化
用useMemo包裹useVirtualizer,依赖filteredOptions.length和open,确保Popover打开或选项列表变化时重新初始化虚拟列表:
const virtualizer = React.useMemo(() => { return useVirtualizer({ count: filteredOptions.length, getScrollElement: () => parentRef.current, estimateSize: () => 35, overscan: 5, }); }, [filteredOptions.length, open]);
2. 修复表单值同步逻辑
在CommandItem的onSelect中,将选中的currentValue同步到表单:
onSelect={(currentValue) => { const isDeselect = currentValue === selectedOption; setSelectedOption(isDeselect ? '' : currentValue); form.setValue('service', isDeselect ? '' : currentValue); setOpen(false); }}
3. 优化搜索状态重置
添加状态管理搜索框值,在Popover关闭时清空搜索内容并重置选项列表:
const [searchValue, setSearchValue] = React.useState(''); // 处理搜索逻辑 const handleSearch = (search: string) => { setSearchValue(search); setFilteredOptions( options.filter((option) => option.value.toLowerCase().includes(search.toLowerCase()) ), ); }; // 关闭Popover时重置搜索状态 React.useEffect(() => { if (!open) { setSearchValue(''); setFilteredOptions(options); } }, [open, options]); // 绑定搜索框值 <CommandInput placeholder="Search IT Service..." className="h-9" value={searchValue} onValueChange={handleSearch} />
4. 强制虚拟列表重新计算
Popover打开后,触发虚拟列表重新计算,解决滚动容器延迟挂载的问题:
React.useEffect(() => { if (open && parentRef.current) { virtualizer.recomputeVirtualItems(); } }, [open, virtualizer]);
完整修复代码
'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { cn } from '@/app/lib/utils'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { toast } from '@/components/ui/use-toast'; import * as React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; type Option = { value: string; label: string; }; const FormSchema = z.object({ service: z.string({ required_error: 'Please select an IT Service.', }), }); export function ComboboxForm({ data }: { data: string[] }) { const options = data.map((item) => ({ value: item, label: item, })); const form = useForm<z.infer<typeof FormSchema>>({ resolver: zodResolver(FormSchema), }); const [open, setOpen] = React.useState<boolean>(false); const [filteredOptions, setFilteredOptions] = React.useState<Option[]>(options); const [selectedOption, setSelectedOption] = React.useState<string>(''); const [searchValue, setSearchValue] = React.useState(''); const parentRef = React.useRef<HTMLDivElement>(null); const virtualizer = React.useMemo(() => { return useVirtualizer({ count: filteredOptions.length, getScrollElement: () => parentRef.current, estimateSize: () => 35, overscan: 5, }); }, [filteredOptions.length, open]); const virtualOptions = virtualizer.getVirtualItems(); const handleSearch = (search: string) => { setSearchValue(search); setFilteredOptions( options.filter((option) => option.value.toLowerCase().includes(search.toLowerCase()) ), ); }; React.useEffect(() => { if (!open) { setSearchValue(''); setFilteredOptions(options); } }, [open, options]); React.useEffect(() => { if (open && parentRef.current) { virtualizer.recomputeVirtualItems(); } }, [open, virtualizer]); function onSubmit(data: z.infer<typeof FormSchema>) { toast({ title: 'You submitted the following values:', description: ( <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> <code className="text-white">{JSON.stringify(data, null, 2)}</code> </pre> ), }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <FormField control={form.control} name="service" render={({ field }) => ( <FormItem className="flex flex-col"> <FormLabel>IT Service</FormLabel> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <FormControl> <Button variant="outline" role="combobox" className={cn( 'w-[384px] justify-between', !selectedOption && 'text-muted-foreground', )} > {selectedOption ? options.find((option) => option.value === selectedOption)?.label : 'Select IT Service'} <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </FormControl> </PopoverTrigger> <PopoverContent className="w-[384px] p-0"> <Command shouldFilter={false}> <CommandInput placeholder="Search IT Service..." className="h-9" value={searchValue} onValueChange={handleSearch} /> <CommandEmpty>No IT Service found.</CommandEmpty> <CommandList> <CommandGroup ref={parentRef} style={{ height: '208px', width: '100%', overflow: 'auto', }} > <div style={{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative', }} > {virtualOptions.map((virtualOption) => { const option = filteredOptions[virtualOption.index]; return ( <CommandItem key={option.value} value={option.value} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualOption.size}px`, transform: `translateY(${virtualOption.start}px)`, }} onSelect={(currentValue) => { const isDeselect = currentValue === selectedOption; setSelectedOption(isDeselect ? '' : currentValue); form.setValue('service', isDeselect ? '' : currentValue); setOpen(false); }} className="py-2" > {option.label} <CheckIcon className={cn( 'ml-auto h-4 w-4', option.value === field.value ? 'opacity-100' : 'opacity-0', )} /> </CommandItem> ); })} </div> </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <FormDescription> This is the IT service to be updated. </FormDescription> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> ); }
内容的提问来源于stack exchange,提问作者moth
相关产品推荐
相关产品推荐

