505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import * as React from 'react';
|
|||
|
|
import { useState } from 'react';
|
|||
|
|
import type { TermTreeNode } from '@fenghuo/common';
|
|||
|
|
import { Button } from '@nice/ui/components/button';
|
|||
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@nice/ui/components/popover';
|
|||
|
|
import {
|
|||
|
|
Command,
|
|||
|
|
CommandEmpty,
|
|||
|
|
CommandGroup,
|
|||
|
|
CommandInput,
|
|||
|
|
CommandItem,
|
|||
|
|
CommandList,
|
|||
|
|
} from '@nice/ui/components/command';
|
|||
|
|
import { IconChevronDown, IconChevronRight, IconCheck, IconTag, IconX } from '@tabler/icons-react';
|
|||
|
|
import { cn } from '@nice/ui/lib/utils';
|
|||
|
|
import { useTRPC } from '@fenghuo/client';
|
|||
|
|
import { useQuery } from '@tanstack/react-query';
|
|||
|
|
import { Term } from '@fenghuo/db';
|
|||
|
|
import { Badge } from '@nice/ui/components/badge';
|
|||
|
|
|
|||
|
|
// 术语树节点接口 - 已移至 @fenghuo/common
|
|||
|
|
|
|||
|
|
// 树形选择器属性
|
|||
|
|
interface TermTreeSelectorProps {
|
|||
|
|
taxonomySlug: string; // 使用slug来获取分类
|
|||
|
|
value?: string | string[]; // 支持单选和多选
|
|||
|
|
onValueChange?: (value: string | string[]) => void; // 支持单选和多选
|
|||
|
|
placeholder?: string;
|
|||
|
|
className?: string;
|
|||
|
|
disabled?: boolean;
|
|||
|
|
allowClear?: boolean;
|
|||
|
|
excludeIds?: string[]; // 排除的ID列表
|
|||
|
|
multiple?: boolean; // 是否支持多选
|
|||
|
|
maxSelections?: number; // 最大选择数量(仅多选模式)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 树形节点组件
|
|||
|
|
interface TreeNodeProps {
|
|||
|
|
node: TermTreeNode;
|
|||
|
|
level: number;
|
|||
|
|
selectedValues: string[]; // 改为数组以支持多选
|
|||
|
|
expandedIds: Set<string>;
|
|||
|
|
onSelect: (value: string, name: string) => void;
|
|||
|
|
onToggleExpand: (id: string) => void;
|
|||
|
|
multiple?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TreeNode({ node, level, selectedValues, expandedIds, onSelect, onToggleExpand, multiple }: TreeNodeProps) {
|
|||
|
|
const hasChildren = node.children && node.children.length > 0;
|
|||
|
|
const isExpanded = expandedIds.has(node.id);
|
|||
|
|
const isSelected = selectedValues.includes(node.id);
|
|||
|
|
|
|||
|
|
const handleSelect = () => {
|
|||
|
|
onSelect(node.id, node.name);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleToggleExpand = (e: React.MouseEvent) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (hasChildren) {
|
|||
|
|
onToggleExpand(node.id);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<CommandItem
|
|||
|
|
value={`${node.id}-${node.name}`}
|
|||
|
|
onSelect={handleSelect}
|
|||
|
|
className={cn(
|
|||
|
|
'flex items-center justify-between cursor-pointer',
|
|||
|
|
'hover:bg-accent hover:text-accent-foreground',
|
|||
|
|
isSelected && 'bg-accent text-accent-foreground',
|
|||
|
|
)}
|
|||
|
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 flex-1">
|
|||
|
|
{/* 展开/折叠图标 */}
|
|||
|
|
<div className="w-4 h-4 flex items-center justify-center">
|
|||
|
|
{hasChildren ? (
|
|||
|
|
<button onClick={handleToggleExpand} className="hover:bg-muted/50 rounded-sm p-0.5">
|
|||
|
|
{isExpanded ? <IconChevronDown className="h-3 w-3" /> : <IconChevronRight className="h-3 w-3" />}
|
|||
|
|
</button>
|
|||
|
|
) : (
|
|||
|
|
<div className="w-3 h-3" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 术语图标 */}
|
|||
|
|
<IconTag className="h-4 w-4 text-muted-foreground" />
|
|||
|
|
|
|||
|
|
{/* 术语名称 */}
|
|||
|
|
<span className="flex-1 truncate">{node.name}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 选中状态指示 */}
|
|||
|
|
{isSelected && <IconCheck className="h-4 w-4 text-primary" />}
|
|||
|
|
</CommandItem>
|
|||
|
|
|
|||
|
|
{/* 子节点 */}
|
|||
|
|
{hasChildren && isExpanded && (
|
|||
|
|
<>
|
|||
|
|
{node.children!.map((child) => (
|
|||
|
|
<TreeNode
|
|||
|
|
key={child.id}
|
|||
|
|
node={child}
|
|||
|
|
level={level + 1}
|
|||
|
|
selectedValues={selectedValues}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
onToggleExpand={onToggleExpand}
|
|||
|
|
multiple={multiple}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 主组件
|
|||
|
|
export function TermSelect({
|
|||
|
|
taxonomySlug,
|
|||
|
|
value,
|
|||
|
|
onValueChange,
|
|||
|
|
placeholder = '选择分类',
|
|||
|
|
className,
|
|||
|
|
disabled = false,
|
|||
|
|
allowClear = true,
|
|||
|
|
excludeIds = [],
|
|||
|
|
multiple = false,
|
|||
|
|
maxSelections,
|
|||
|
|
}: TermTreeSelectorProps) {
|
|||
|
|
const [open, setOpen] = useState(false);
|
|||
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|||
|
|
const [searchValue, setSearchValue] = useState('');
|
|||
|
|
|
|||
|
|
const trpc = useTRPC();
|
|||
|
|
|
|||
|
|
// 根据 taxonomySlug 获取术语树
|
|||
|
|
const {
|
|||
|
|
data: termTree = [],
|
|||
|
|
isLoading,
|
|||
|
|
error,
|
|||
|
|
} = useQuery({
|
|||
|
|
...trpc.term.getTreeByTaxonomy.queryOptions({
|
|||
|
|
taxonomySlug,
|
|||
|
|
}),
|
|||
|
|
enabled: !!taxonomySlug,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 过滤排除的术语
|
|||
|
|
const terms = React.useMemo(() => {
|
|||
|
|
const filterExcluded = (nodes: TermTreeNode[]): Term[] => {
|
|||
|
|
return nodes
|
|||
|
|
.filter((node) => !excludeIds.includes(node.id))
|
|||
|
|
.map((node) => ({
|
|||
|
|
...node,
|
|||
|
|
children: node.children ? filterExcluded(node.children) : undefined,
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
return filterExcluded(termTree);
|
|||
|
|
}, [termTree, excludeIds]);
|
|||
|
|
|
|||
|
|
// 构建术语树 - 后端已返回树形,只需排序
|
|||
|
|
const buildTermTree = React.useMemo(() => {
|
|||
|
|
if (!terms || terms.length === 0) {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 递归排序
|
|||
|
|
const sortNodes = (nodes: TermTreeNode[]): TermTreeNode[] => {
|
|||
|
|
return nodes
|
|||
|
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|||
|
|
.map((node) => ({
|
|||
|
|
...node,
|
|||
|
|
children: node.children ? sortNodes(node.children) : undefined,
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return sortNodes(terms);
|
|||
|
|
}, [terms]);
|
|||
|
|
|
|||
|
|
// 扁平化所有术语,用于查找和路径计算
|
|||
|
|
const flatTerms = React.useMemo(() => {
|
|||
|
|
const flatten = (nodes: TermTreeNode[]): Term[] => {
|
|||
|
|
return nodes.reduce<Term[]>((acc, node) => {
|
|||
|
|
acc.push(node);
|
|||
|
|
if (node.children) {
|
|||
|
|
acc.push(...flatten(node.children));
|
|||
|
|
}
|
|||
|
|
return acc;
|
|||
|
|
}, []);
|
|||
|
|
};
|
|||
|
|
return flatten(terms);
|
|||
|
|
}, [terms]);
|
|||
|
|
|
|||
|
|
// 标准化选中值为数组
|
|||
|
|
const selectedValues = React.useMemo(() => {
|
|||
|
|
if (!value) return [];
|
|||
|
|
if (Array.isArray(value)) return value;
|
|||
|
|
return [value];
|
|||
|
|
}, [value]);
|
|||
|
|
|
|||
|
|
// 获取选中术语的信息
|
|||
|
|
const selectedTerms = React.useMemo(() => {
|
|||
|
|
return selectedValues.map((val) => flatTerms.find((term) => term.id === val)).filter(Boolean) as Term[];
|
|||
|
|
}, [selectedValues, flatTerms]);
|
|||
|
|
|
|||
|
|
// 获取术语的完整路径
|
|||
|
|
const getTermPath = React.useCallback(
|
|||
|
|
(termId: string): string => {
|
|||
|
|
const term = flatTerms.find((d) => d.id === termId);
|
|||
|
|
if (!term) return '';
|
|||
|
|
|
|||
|
|
const path: string[] = [];
|
|||
|
|
let current: Term | undefined = term;
|
|||
|
|
|
|||
|
|
while (current) {
|
|||
|
|
path.unshift(current.name);
|
|||
|
|
current = flatTerms.find((d) => d.id === current?.parentId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return path.join(' / ');
|
|||
|
|
},
|
|||
|
|
[flatTerms],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 切换展开状态
|
|||
|
|
const toggleExpand = (id: string) => {
|
|||
|
|
setExpandedIds((prev) => {
|
|||
|
|
const newSet = new Set(prev);
|
|||
|
|
if (newSet.has(id)) {
|
|||
|
|
newSet.delete(id);
|
|||
|
|
} else {
|
|||
|
|
newSet.add(id);
|
|||
|
|
}
|
|||
|
|
return newSet;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理选择
|
|||
|
|
const handleSelect = (selectedValue: string, selectedName: string) => {
|
|||
|
|
if (multiple) {
|
|||
|
|
// 多选模式
|
|||
|
|
const newValues = [...selectedValues];
|
|||
|
|
const index = newValues.indexOf(selectedValue);
|
|||
|
|
|
|||
|
|
if (index >= 0) {
|
|||
|
|
// 已选中,取消选择
|
|||
|
|
newValues.splice(index, 1);
|
|||
|
|
} else {
|
|||
|
|
// 未选中,添加选择(检查最大选择数)
|
|||
|
|
if (!maxSelections || newValues.length < maxSelections) {
|
|||
|
|
newValues.push(selectedValue);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onValueChange?.(newValues);
|
|||
|
|
} else {
|
|||
|
|
// 单选模式
|
|||
|
|
onValueChange?.(selectedValue);
|
|||
|
|
setOpen(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理清除
|
|||
|
|
const handleClear = (e: React.MouseEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
onValueChange?.(multiple ? [] : '');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理单个项目移除(仅多选模式)
|
|||
|
|
const handleRemoveItem = (termId: string, e: React.MouseEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (multiple) {
|
|||
|
|
const newValues = selectedValues.filter((id) => id !== termId);
|
|||
|
|
onValueChange?.(newValues);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 过滤术语(支持搜索)
|
|||
|
|
const filterTerms = React.useCallback((nodes: TermTreeNode[], search: string): TermTreeNode[] => {
|
|||
|
|
if (!search) return nodes;
|
|||
|
|
|
|||
|
|
const filtered: TermTreeNode[] = [];
|
|||
|
|
const lowerCaseSearch = search.toLowerCase();
|
|||
|
|
|
|||
|
|
for (const node of nodes) {
|
|||
|
|
const matches = node.name.toLowerCase().includes(lowerCaseSearch);
|
|||
|
|
const filteredChildren = node.children ? filterTerms(node.children, search) : [];
|
|||
|
|
|
|||
|
|
if (matches || filteredChildren.length > 0) {
|
|||
|
|
filtered.push({
|
|||
|
|
...node,
|
|||
|
|
children: filteredChildren.length > 0 ? filteredChildren : node.children,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return filtered;
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const filteredTree = React.useMemo(() => {
|
|||
|
|
return filterTerms(buildTermTree, searchValue);
|
|||
|
|
}, [buildTermTree, searchValue, filterTerms]);
|
|||
|
|
|
|||
|
|
// 处理搜索时的自动展开逻辑
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
if (searchValue) {
|
|||
|
|
// 在useEffect内部计算过滤结果,避免依赖filteredTree
|
|||
|
|
const filtered = filterTerms(buildTermTree, searchValue);
|
|||
|
|
|
|||
|
|
if (filtered.length > 0) {
|
|||
|
|
const idsToExpand = new Set<string>();
|
|||
|
|
|
|||
|
|
const collectExpandIds = (nodes: TermTreeNode[]) => {
|
|||
|
|
nodes.forEach((node) => {
|
|||
|
|
// 如果节点名称匹配搜索条件,展开该节点
|
|||
|
|
if (node.name.toLowerCase().includes(searchValue.toLowerCase())) {
|
|||
|
|
idsToExpand.add(node.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果有子节点,展开该节点并递归处理子节点
|
|||
|
|
if (node.children && node.children.length > 0) {
|
|||
|
|
idsToExpand.add(node.id);
|
|||
|
|
collectExpandIds(node.children);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
collectExpandIds(filtered);
|
|||
|
|
|
|||
|
|
// 批量更新展开状态
|
|||
|
|
setExpandedIds((prev) => new Set([...prev, ...idsToExpand]));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, [searchValue, filterTerms, buildTermTree]);
|
|||
|
|
|
|||
|
|
// 渲染触发器内容
|
|||
|
|
const renderTriggerContent = () => {
|
|||
|
|
if (selectedTerms.length === 0) {
|
|||
|
|
return <span className="truncate">{placeholder}</span>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (multiple) {
|
|||
|
|
if (selectedTerms.length === 1) {
|
|||
|
|
return <span className="truncate">{getTermPath(selectedTerms[0]?.id || '')}</span>;
|
|||
|
|
} else {
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-wrap gap-1 justify-start">
|
|||
|
|
{selectedTerms.slice(0, 3).map((term) => (
|
|||
|
|
<Badge key={term.id} variant="secondary" className="text-xs px-1.5 py-0.5 h-5 max-w-[120px]">
|
|||
|
|
<span className="truncate">{term.name}</span>
|
|||
|
|
<div
|
|||
|
|
onClick={(e) => handleRemoveItem(term.id, e)}
|
|||
|
|
className="ml-1 hover:bg-muted-foreground/20 rounded-sm p-0.5 cursor-pointer"
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
aria-label="移除此项"
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (multiple) {
|
|||
|
|
const newValues = selectedValues.filter((id) => id !== term.id);
|
|||
|
|
onValueChange?.(newValues);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<IconX className="h-2.5 w-2.5" />
|
|||
|
|
</div>
|
|||
|
|
</Badge>
|
|||
|
|
))}
|
|||
|
|
{selectedTerms.length > 3 && (
|
|||
|
|
<Badge variant="secondary" className="text-xs px-1.5 py-0.5 h-5">
|
|||
|
|
+{selectedTerms.length - 1}
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
return <span className="truncate">{getTermPath(selectedTerms[0]?.id || '')}</span>;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 加载状态
|
|||
|
|
if (isLoading) {
|
|||
|
|
return (
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
className={cn('w-[150px] h-8 justify-start text-sm font-normal text-muted-foreground', className)}
|
|||
|
|
disabled
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 flex-1">
|
|||
|
|
<IconTag className="h-4 w-4 text-muted-foreground" />
|
|||
|
|
<span>加载中...</span>
|
|||
|
|
</div>
|
|||
|
|
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
|
|||
|
|
</Button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 错误状态
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
className={cn('w-[150px] h-8 justify-start text-sm font-normal text-muted-foreground', className)}
|
|||
|
|
disabled
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 flex-1">
|
|||
|
|
<IconTag className="h-4 w-4 text-muted-foreground" />
|
|||
|
|
<span>加载失败</span>
|
|||
|
|
</div>
|
|||
|
|
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
|
|||
|
|
</Button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|||
|
|
<PopoverTrigger asChild>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
role="combobox"
|
|||
|
|
aria-expanded={open}
|
|||
|
|
className={cn(
|
|||
|
|
'w-[150px] h-8 justify-start text-sm font-normal',
|
|||
|
|
selectedTerms.length === 0 && 'text-muted-foreground',
|
|||
|
|
className,
|
|||
|
|
)}
|
|||
|
|
disabled={disabled}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|||
|
|
<IconTag className="h-4 w-4 text-muted-foreground shrink-0" />
|
|||
|
|
<div className="flex-1 min-w-0 text-left">{renderTriggerContent()}</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
|||
|
|
{allowClear && selectedTerms.length > 0 && (
|
|||
|
|
<div
|
|||
|
|
onClick={handleClear}
|
|||
|
|
className="flex items-center justify-center w-4 h-4 rounded-sm hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
aria-label="清除选择"
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
if (selectedTerms.length > 0) {
|
|||
|
|
onValueChange?.(multiple ? [] : '');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<IconX className="h-3 w-3" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|||
|
|
</div>
|
|||
|
|
</Button>
|
|||
|
|
</PopoverTrigger>
|
|||
|
|
<PopoverContent className="p-0 min-w-[150px] w-[var(--radix-popover-trigger-width)]" align="start">
|
|||
|
|
<Command>
|
|||
|
|
<CommandInput
|
|||
|
|
placeholder={multiple ? '搜索分类...' : '搜索分类...'}
|
|||
|
|
value={searchValue}
|
|||
|
|
onValueChange={setSearchValue}
|
|||
|
|
className="h-9"
|
|||
|
|
/>
|
|||
|
|
<CommandList className="max-h-[300px]">
|
|||
|
|
<CommandEmpty>未找到分类</CommandEmpty>
|
|||
|
|
<CommandGroup>
|
|||
|
|
{/* 多选模式下显示已选择数量 */}
|
|||
|
|
{multiple && selectedTerms.length > 0 && (
|
|||
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b">
|
|||
|
|
已选择 {selectedTerms.length} 项{maxSelections && ` / ${maxSelections}`}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{filteredTree.map((node) => (
|
|||
|
|
<TreeNode
|
|||
|
|
key={node.id}
|
|||
|
|
node={node}
|
|||
|
|
level={0}
|
|||
|
|
selectedValues={selectedValues}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
onSelect={handleSelect}
|
|||
|
|
onToggleExpand={toggleExpand}
|
|||
|
|
multiple={multiple}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</CommandGroup>
|
|||
|
|
</CommandList>
|
|||
|
|
</Command>
|
|||
|
|
</PopoverContent>
|
|||
|
|
</Popover>
|
|||
|
|
);
|
|||
|
|
}
|