mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 21:35:25 +00:00
perf: add virtualized scrolling to search scope selector
This commit is contained in:
parent
154c95f4ee
commit
d0cb69fdbe
1 changed files with 258 additions and 150 deletions
|
|
@ -1,34 +1,29 @@
|
||||||
// Adapted from: web/src/components/ui/multi-select.tsx
|
// Adapted from: web/src/components/ui/multi-select.tsx
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import {
|
|
||||||
CheckIcon,
|
|
||||||
ChevronDown,
|
|
||||||
ScanSearchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { RepositoryQuery, SearchContextQuery } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Command,
|
CheckIcon,
|
||||||
CommandEmpty,
|
ChevronDown,
|
||||||
CommandGroup,
|
ScanSearchIcon,
|
||||||
CommandInput,
|
} from "lucide-react";
|
||||||
CommandItem,
|
import { ButtonHTMLAttributes, forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
CommandList,
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
CommandSeparator,
|
import { RepoSearchScope, RepoSetSearchScope, SearchScope } from "../../types";
|
||||||
} from "@/components/ui/command";
|
|
||||||
import { RepoSetSearchScope, RepoSearchScope, SearchScope } from "../../types";
|
|
||||||
import { SearchScopeIcon } from "../searchScopeIcon";
|
import { SearchScopeIcon } from "../searchScopeIcon";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { SearchScopeInfoCard } from "./searchScopeInfoCard";
|
||||||
|
|
||||||
interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface SearchScopeSelectorProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
repos: RepositoryQuery[];
|
repos: RepositoryQuery[];
|
||||||
searchContexts: SearchContextQuery[];
|
searchContexts: SearchContextQuery[];
|
||||||
selectedSearchScopes: SearchScope[];
|
selectedSearchScopes: SearchScope[];
|
||||||
|
|
@ -38,7 +33,7 @@ interface SearchScopeSelectorProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||||
onOpenChanged: (isOpen: boolean) => void;
|
onOpenChanged: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchScopeSelector = React.forwardRef<
|
export const SearchScopeSelector = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
SearchScopeSelectorProps
|
SearchScopeSelectorProps
|
||||||
>(
|
>(
|
||||||
|
|
@ -55,23 +50,13 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollPosition = React.useRef<number>(0);
|
const scrollPosition = useRef<number>(0);
|
||||||
const [hasSearchInput, setHasSearchInput] = React.useState(false);
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
||||||
|
|
||||||
const handleInputKeyDown = (
|
const toggleItem = useCallback((item: SearchScope) => {
|
||||||
event: React.KeyboardEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
onOpenChanged(true);
|
|
||||||
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
|
||||||
const newSelectedItems = [...selectedSearchScopes];
|
|
||||||
newSelectedItems.pop();
|
|
||||||
onSelectedSearchScopesChange(newSelectedItems);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleItem = (item: SearchScope) => {
|
|
||||||
// Store current scroll position before state update
|
// Store current scroll position before state update
|
||||||
if (scrollContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
scrollPosition.current = scrollContainerRef.current.scrollTop;
|
||||||
|
|
@ -88,21 +73,9 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
[...selectedSearchScopes, item];
|
[...selectedSearchScopes, item];
|
||||||
|
|
||||||
onSelectedSearchScopesChange(newSelectedItems);
|
onSelectedSearchScopesChange(newSelectedItems);
|
||||||
};
|
}, [selectedSearchScopes, onSelectedSearchScopesChange]);
|
||||||
|
|
||||||
const handleClear = () => {
|
const allSearchScopeItems = useMemo(() => {
|
||||||
onSelectedSearchScopesChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
onSelectedSearchScopesChange(allSearchScopeItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTogglePopover = () => {
|
|
||||||
onOpenChanged(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSearchScopeItems = React.useMemo(() => {
|
|
||||||
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
const repoSetSearchScopeItems: RepoSetSearchScope[] = searchContexts.map(context => ({
|
||||||
type: 'reposet' as const,
|
type: 'reposet' as const,
|
||||||
value: context.name,
|
value: context.name,
|
||||||
|
|
@ -120,8 +93,40 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
return [...repoSetSearchScopeItems, ...repoSearchScopeItems];
|
||||||
}, [repos, searchContexts]);
|
}, [repos, searchContexts]);
|
||||||
|
|
||||||
const sortedSearchScopeItems = React.useMemo(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
onSelectedSearchScopesChange([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [onSelectedSearchScopesChange]);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
onSelectedSearchScopesChange(allSearchScopeItems);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [onSelectedSearchScopesChange, allSearchScopeItems]);
|
||||||
|
|
||||||
|
const handleTogglePopover = useCallback(() => {
|
||||||
|
onOpenChanged(!isOpen);
|
||||||
|
}, [onOpenChanged, isOpen]);
|
||||||
|
|
||||||
|
const sortedSearchScopeItems = useMemo(() => {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
|
||||||
return allSearchScopeItems
|
return allSearchScopeItems
|
||||||
|
.filter((item) => {
|
||||||
|
// Filter by search query
|
||||||
|
if (query && !item.name.toLowerCase().includes(query) && !item.value.toLowerCase().includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
item,
|
item,
|
||||||
isSelected: selectedSearchScopes.some(
|
isSelected: selectedSearchScopes.some(
|
||||||
|
|
@ -137,10 +142,77 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
if (a.item.type === 'repo' && b.item.type === 'reposet') return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
}, [allSearchScopeItems, selectedSearchScopes]);
|
}, [allSearchScopeItems, selectedSearchScopes, searchQuery]);
|
||||||
|
|
||||||
|
const handleInputKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev < sortedSearchScopeItems.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightedIndex((prev) => prev > 0 ? prev - 1 : 0);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (sortedSearchScopeItems.length > 0 && highlightedIndex >= 0) {
|
||||||
|
toggleItem(sortedSearchScopeItems[highlightedIndex].item);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
||||||
|
const newSelectedItems = [...selectedSearchScopes];
|
||||||
|
newSelectedItems.pop();
|
||||||
|
onSelectedSearchScopesChange(newSelectedItems);
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, onSelectedSearchScopesChange, selectedSearchScopes, sortedSearchScopeItems, toggleItem]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: sortedSearchScopeItems.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: () => 36,
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset highlighted index and scroll to top when search query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Reset highlighted index when items change (but don't scroll)
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [sortedSearchScopeItems.length]);
|
||||||
|
|
||||||
|
// Measure virtualizer when popover opens and container is mounted
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsMounted(true);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
// Give the DOM a tick to render before measuring
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
virtualizer.measure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsMounted(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, virtualizer]);
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMounted && highlightedIndex >= 0) {
|
||||||
|
virtualizer.scrollToIndex(highlightedIndex, {
|
||||||
|
align: 'auto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, isMounted, virtualizer]);
|
||||||
|
|
||||||
// Restore scroll position after re-render
|
// Restore scroll position after re-render
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
if (scrollContainerRef.current && scrollPosition.current > 0) {
|
||||||
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
scrollContainerRef.current.scrollTop = scrollPosition.current;
|
||||||
}
|
}
|
||||||
|
|
@ -151,106 +223,142 @@ export const SearchScopeSelector = React.forwardRef<
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={onOpenChanged}
|
onOpenChange={onOpenChanged}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
ref={ref}
|
<TooltipTrigger asChild>
|
||||||
{...props}
|
<Button
|
||||||
onClick={handleTogglePopover}
|
ref={ref}
|
||||||
className={cn(
|
{...props}
|
||||||
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
onClick={handleTogglePopover}
|
||||||
className
|
className={cn(
|
||||||
)}
|
"flex p-1 rounded-md items-center justify-between bg-inherit h-6",
|
||||||
>
|
className
|
||||||
<div className="flex items-center justify-between w-full mx-auto">
|
)}
|
||||||
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
|
||||||
<span
|
|
||||||
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
|
||||||
>
|
>
|
||||||
{
|
<div className="flex items-center justify-between w-full mx-auto">
|
||||||
selectedSearchScopes.length === 0 ? `Search scopes` :
|
<ScanSearchIcon className="h-4 w-4 text-muted-foreground mr-1" />
|
||||||
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
<span
|
||||||
`${selectedSearchScopes.length} selected`
|
className={cn("text-sm text-muted-foreground mx-1 font-medium")}
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-auto p-0"
|
|
||||||
align="start"
|
|
||||||
onEscapeKeyDown={() => onOpenChanged(false)}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search scopes..."
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
onValueChange={(value) => setHasSearchInput(!!value)}
|
|
||||||
/>
|
|
||||||
<CommandList ref={scrollContainerRef}>
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{!hasSearchInput && (
|
|
||||||
<div
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
|
||||||
>
|
>
|
||||||
<span className="text-xs">Select all</span>
|
{
|
||||||
|
selectedSearchScopes.length === 0 ? `Search scopes` :
|
||||||
|
selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name :
|
||||||
|
`${selectedSearchScopes.length} selected`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 cursor-pointer text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<TooltipContent side="bottom" className="p-0 border-0 bg-transparent shadow-none">
|
||||||
|
<SearchScopeInfoCard />
|
||||||
|
</TooltipContent>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[400px] p-0"
|
||||||
|
align="start"
|
||||||
|
onEscapeKeyDown={() => onOpenChanged(false)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search scopes..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="max-h-[300px] overflow-auto"
|
||||||
|
>
|
||||||
|
{sortedSearchScopeItems.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No results found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1">
|
||||||
|
{!searchQuery && (
|
||||||
|
<div
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="flex items-center px-2 py-1.5 text-sm text-muted-foreground hover:text-foreground cursor-pointer transition-colors rounded-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="text-xs">Select all</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMounted && virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const { item, isSelected } = sortedSearchScopeItems[virtualItem.index];
|
||||||
|
const isHighlighted = virtualItem.index === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.type}-${item.value}`}
|
||||||
|
onClick={() => toggleItem(item)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(virtualItem.index)}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer absolute top-0 left-0 w-full flex items-center px-2 py-1.5 text-sm rounded-sm",
|
||||||
|
isHighlighted ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<SearchScopeIcon searchScope={item} />
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{item.type === 'reposet' && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sortedSearchScopeItems.map(({ item, isSelected }) => {
|
</div>
|
||||||
return (
|
{selectedSearchScopes.length > 0 && (
|
||||||
<CommandItem
|
<>
|
||||||
key={`${item.type}-${item.value}`}
|
<Separator />
|
||||||
onSelect={() => toggleItem(item)}
|
<div
|
||||||
className="cursor-pointer"
|
onClick={handleClear}
|
||||||
>
|
className="flex items-center justify-center px-2 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
<div
|
>
|
||||||
className={cn(
|
Clear
|
||||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
</div>
|
||||||
isSelected
|
</>
|
||||||
? "bg-primary text-primary-foreground"
|
)}
|
||||||
: "opacity-50 [&_svg]:invisible"
|
</div>
|
||||||
)}
|
</PopoverContent>
|
||||||
>
|
</Tooltip>
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<SearchScopeIcon searchScope={item} />
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
{item.type === 'reposet' && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4 bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
{item.repoCount} repo{item.repoCount === 1 ? '' : 's'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
{selectedSearchScopes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<CommandSeparator />
|
|
||||||
<CommandItem
|
|
||||||
onSelect={handleClear}
|
|
||||||
className="flex-1 justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</CommandItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue