Files
chartdb/src/components/select-box/select-box.tsx
Guy Ben-Aharon 34475add32 feat: create relationships on canvas modal (#946)
* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* fix

* fix

* fix

* fix
2025-10-13 18:08:28 +03:00

490 lines
20 KiB
TypeScript

import { CaretSortIcon, CheckIcon, Cross2Icon } from '@radix-ui/react-icons';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/command/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/popover/popover';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
export interface SelectBoxOption {
value: string;
label: string;
description?: string;
regex?: string;
extractRegex?: RegExp;
group?: string;
icon?: React.ReactNode;
}
export interface SelectBoxProps {
options: SelectBoxOption[];
value?: string[] | string;
valueSuffix?: string;
optionSuffix?: (option: SelectBoxOption) => string;
onChange?: (
values: string[] | string,
regexMatches?: string[] | string
) => void;
placeholder?: string;
inputPlaceholder?: string;
emptyPlaceholder?: string;
className?: string;
multiple?: boolean;
oneLine?: boolean;
selectAll?: boolean;
deselectAll?: boolean;
clearText?: string;
showClear?: boolean;
keepOrder?: boolean;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
popoverClassName?: string;
readonly?: boolean;
footerButtons?: React.ReactNode;
commandOnMouseDown?: (e: React.MouseEvent) => void;
commandOnClick?: (e: React.MouseEvent) => void;
onSearchChange?: (search: string) => void;
}
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
(
{
inputPlaceholder,
emptyPlaceholder,
placeholder,
className,
options,
value,
valueSuffix,
onChange,
multiple,
oneLine,
selectAll,
optionSuffix,
deselectAll,
clearText,
showClear,
keepOrder,
disabled,
open,
onOpenChange: setOpen,
popoverClassName,
readonly,
footerButtons,
commandOnMouseDown,
commandOnClick,
onSearchChange,
},
ref
) => {
const [searchTerm, setSearchTerm] = React.useState<string>('');
const [isOpen, setIsOpen] = React.useState(open ?? false);
const { t } = useTranslation();
useEffect(() => {
setIsOpen(open ?? false);
}, [open]);
const onOpenChange = React.useCallback(
(isOpen: boolean) => {
setOpen?.(isOpen);
setIsOpen(isOpen);
if (isOpen) {
setSearchTerm('');
}
setTimeout(() => (document.body.style.pointerEvents = ''), 500);
},
[setOpen]
);
const handleSelect = React.useCallback(
(selectedValue: string, regexMatches?: string[]) => {
if (multiple) {
const newValue =
value?.includes(selectedValue) && Array.isArray(value)
? value.filter((v) => v !== selectedValue)
: [...(value ?? []), selectedValue];
onChange?.(newValue);
} else {
onChange?.(selectedValue, regexMatches);
setIsOpen(false);
}
},
[multiple, onChange, value]
);
const handleClear = React.useCallback(() => {
if (!multiple) return;
onChange?.(multiple ? [] : '');
}, [multiple, onChange]);
const handleSelectAll = React.useCallback(() => {
if (!multiple) return;
const allIds = options.map((option) => option.value);
onChange?.(allIds);
}, [multiple, onChange, options]);
const selectedMultipleOptions = React.useMemo(
() =>
options
.filter(
(option) =>
Array.isArray(value) && value.includes(option.value)
)
.sort((a, b) => {
if (keepOrder && Array.isArray(value)) {
return (
value.indexOf(a.value) - value.indexOf(b.value)
);
}
return 0;
})
.map((option) => (
<span
key={option.value}
className={`inline-flex min-w-0 shrink-0 items-center gap-1 rounded-md border py-0.5 pl-2 pr-1 text-xs font-medium text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${oneLine ? 'mx-0.5' : ''}`}
>
<span>{option.label}</span>
{!readonly ? (
<span
onClick={(e) => {
e.preventDefault();
handleSelect(option.value);
}}
className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground"
>
<Cross2Icon />
</span>
) : null}
</span>
)),
[options, value, handleSelect, oneLine, keepOrder, readonly]
);
const isAllSelected = React.useMemo(
() =>
multiple &&
Array.isArray(value) &&
options.every((option) => value.includes(option.value)),
[options, value, multiple]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen && e.code.toLowerCase() === 'space') {
e.preventDefault();
onOpenChange(true);
}
},
[isOpen, onOpenChange]
);
const groups = React.useMemo(
() =>
options.reduce(
(acc, option) => {
if (option.group) {
if (!acc[option.group]) {
acc[option.group] = [];
}
acc[option.group].push(option);
} else {
if (!acc['default']) {
acc['default'] = [];
}
acc['default'].push(option);
}
return acc;
},
{} as Record<string, SelectBoxOption[]>
),
[options]
);
const hasGroups = React.useMemo(
() =>
Object.keys(groups).filter((group) => group !== 'default')
.length > 0,
[groups]
);
const renderOption = React.useCallback(
(option: SelectBoxOption) => {
const isSelected =
Array.isArray(value) && value.includes(option.value);
const isRegexMatch =
option.regex && new RegExp(option.regex)?.test(searchTerm);
const matches = option.extractRegex
? searchTerm.match(option.extractRegex)
: undefined;
return (
<CommandItem
className="flex items-center"
key={option.value}
value={option.label}
keywords={option.regex ? [option.regex] : undefined}
onSelect={() =>
handleSelect(
option.value,
matches?.map((match) => match?.toString())
)
}
onMouseDown={commandOnMouseDown}
onClick={commandOnClick}
>
{multiple && (
<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 />
</div>
)}
<div className="flex flex-1 items-center truncate">
{option.icon ? (
<span className="mr-2 shrink-0">
{option.icon}
</span>
) : null}
<span>
{isRegexMatch ? searchTerm : option.label}
{!isRegexMatch && optionSuffix
? optionSuffix(option)
: ''}
</span>
{option.description && (
<span className="ml-1 w-0 flex-1 truncate text-xs text-muted-foreground">
{option.description}
</span>
)}
</div>
{((!multiple && option.value === value) ||
isRegexMatch) && (
<CheckIcon
className={cn(
'ml-auto',
option.value === value
? 'opacity-100'
: 'opacity-0'
)}
/>
)}
</CommandItem>
);
},
[
value,
multiple,
searchTerm,
handleSelect,
optionSuffix,
commandOnClick,
commandOnMouseDown,
]
);
return (
<Popover open={isOpen} onOpenChange={onOpenChange} modal={true}>
<PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}>
<div
className={cn(
`flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''} ${readonly ? 'pointer-events-none' : ''}`,
className
)}
>
<div
className={cn(
'items-center gap-1 overflow-hidden text-sm',
multiple
? 'flex flex-grow flex-wrap'
: 'inline-flex whitespace-nowrap'
)}
>
{value && value.length > 0 ? (
multiple ? (
oneLine ? (
<div className="block w-full min-w-0 shrink-0 truncate">
{selectedMultipleOptions}
</div>
) : (
selectedMultipleOptions
)
) : (
<div className="block w-full min-w-0 shrink-0 truncate">
{
options.find(
(opt) => opt.value === value
)?.label
}
{valueSuffix ? valueSuffix : ''}
</div>
)
) : (
<span className="mr-auto text-muted-foreground">
{placeholder}
</span>
)}
</div>
<div className="flex items-center self-stretch pl-1 text-muted-foreground/60 hover:text-foreground [&>div]:flex [&>div]:items-center [&>div]:self-stretch">
{value &&
value.length > 0 &&
multiple &&
showClear ? (
<div
onClick={(e) => {
e.preventDefault();
handleClear();
}}
>
{clearText ? (
<span className="text-xs">
{clearText}
</span>
) : (
<Cross2Icon className="size-3.5" />
)}
</div>
) : (
<div>
<CaretSortIcon className="size-4" />
</div>
)}
</div>
</div>
</PopoverTrigger>
<PopoverContent
className={cn(
'w-fit min-w-[var(--radix-popover-trigger-width)] p-0',
popoverClassName
)}
align="center"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Command
filter={(value, search, keywords) => {
if (
keywords?.length &&
keywords.some((keyword) =>
new RegExp(keyword).test(search)
)
) {
return 1;
}
return value
.toLowerCase()
.includes(search.toLowerCase())
? 1
: 0;
}}
>
<div className="relative">
<CommandInput
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
onSearchChange?.(e);
}}
ref={ref}
placeholder={inputPlaceholder ?? 'Search...'}
className="h-9"
/>
{searchTerm && (
<div
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-muted-foreground hover:text-foreground"
onClick={() => setSearchTerm('')}
>
<Cross2Icon className="size-4" />
</div>
)}
{!searchTerm &&
multiple &&
selectAll &&
!isAllSelected && (
<div
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.preventDefault();
handleSelectAll();
}}
>
{t('select_all')}
</div>
)}
{!searchTerm &&
multiple &&
deselectAll &&
isAllSelected && (
<div
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.preventDefault();
handleClear();
}}
>
{t('deselect_all')}
</div>
)}
</div>
<CommandEmpty>
{emptyPlaceholder ?? 'No results found.'}
</CommandEmpty>
<ScrollArea>
<div className="max-h-64 w-full">
<CommandList className="max-h-fit w-full">
{hasGroups
? Object.entries(groups).map(
([groupName, groupOptions]) => (
<CommandGroup
key={groupName}
heading={groupName}
>
{groupOptions.map(
renderOption
)}
</CommandGroup>
)
)
: options.map(renderOption)}
</CommandList>
</div>
</ScrollArea>
</Command>
{footerButtons ? (
<div className="border-t">{footerButtons}</div>
) : null}
</PopoverContent>
</Popover>
);
}
);
SelectBox.displayName = 'SelectBox';