378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
/**
|
|
* Copyright 2025 LY Corporation
|
|
*
|
|
* LY Corporation licenses this file to you under the Apache License,
|
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at:
|
|
*
|
|
* https://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
* License for the specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Slot, Slottable } from '@radix-ui/react-slot';
|
|
import { cva } from 'class-variance-authority';
|
|
|
|
import { ICON_SIZE } from '../constants';
|
|
import { cn } from '../lib/utils';
|
|
import type { Radius, Size } from '../types';
|
|
import { Icon } from './icon';
|
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
|
import { ScrollArea, ScrollBar } from './scroll-area';
|
|
import { Tag } from './tag';
|
|
import useTheme from './use-theme';
|
|
|
|
type MultiSelectItemType = { value: string; label: React.ReactNode };
|
|
|
|
type MultiSelectContextType = {
|
|
disabled: boolean;
|
|
size: Size;
|
|
radius: Radius;
|
|
selectedItems: MultiSelectItemType[];
|
|
setSelectedItems: (items: MultiSelectItemType[]) => void;
|
|
isOpen: boolean;
|
|
setIsOpen: (open: boolean) => void;
|
|
toggleOption: (item: MultiSelectItemType) => void;
|
|
registeredItems: MultiSelectItemType[];
|
|
registerItem: (item: MultiSelectItemType) => void;
|
|
};
|
|
|
|
const MultiSelectContext = React.createContext<
|
|
MultiSelectContextType | undefined
|
|
>(undefined);
|
|
|
|
function useMultiSelectContext() {
|
|
const ctx = React.useContext(MultiSelectContext);
|
|
if (!ctx)
|
|
throw new Error('MultiSelect components must be used within <MultiSelect>');
|
|
return ctx;
|
|
}
|
|
|
|
interface MultiSelectProps {
|
|
value?: string[];
|
|
defaultValue?: string[];
|
|
onValueChange?: (value: string[]) => void;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
size?: Size;
|
|
radius?: Radius;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const selectVariants = cva('select', {
|
|
variants: {
|
|
size: {
|
|
small: 'select-small',
|
|
medium: 'select-medium',
|
|
large: 'select-large',
|
|
},
|
|
defaultVariants: { size: undefined },
|
|
},
|
|
});
|
|
|
|
const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
function MultiSelect(
|
|
{
|
|
value,
|
|
defaultValue = [],
|
|
onValueChange,
|
|
children,
|
|
className,
|
|
size,
|
|
radius,
|
|
disabled = false,
|
|
},
|
|
ref,
|
|
) {
|
|
const { themeSize, themeRadius } = useTheme();
|
|
|
|
const [registeredItems, setRegisteredItems] = React.useState<
|
|
MultiSelectItemType[]
|
|
>([]);
|
|
const [selectedValues, setSelectedValues] =
|
|
React.useState<string[]>(defaultValue);
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
|
|
const values = value ?? selectedValues;
|
|
const setValues = (vals: string[]) => {
|
|
if (onValueChange) onValueChange(vals);
|
|
if (value === undefined) setSelectedValues(vals);
|
|
};
|
|
|
|
const registerItem = React.useCallback((item: MultiSelectItemType) => {
|
|
setRegisteredItems((prev) =>
|
|
prev.find((i) => i.value === item.value) ? prev : [...prev, item],
|
|
);
|
|
}, []);
|
|
|
|
const selectedItems = React.useMemo(
|
|
() =>
|
|
values
|
|
.map((val) => registeredItems.find((item) => item.value === val))
|
|
.filter(Boolean) as MultiSelectItemType[],
|
|
[values, registeredItems],
|
|
);
|
|
|
|
const toggleOption = (item: MultiSelectItemType) => {
|
|
setValues(
|
|
values.includes(item.value) ?
|
|
values.filter((v) => v !== item.value)
|
|
: [...values, item.value],
|
|
);
|
|
};
|
|
|
|
const hiddenRegisterItems = (
|
|
<div style={{ display: 'none' }}>
|
|
{React.Children.map(children, (child) => {
|
|
if (
|
|
React.isValidElement(child) &&
|
|
(child.type as React.ForwardRefExoticComponent<HTMLElement>)
|
|
.displayName === 'MultiSelectItem'
|
|
) {
|
|
return child;
|
|
}
|
|
if (
|
|
React.isValidElement(child) &&
|
|
(child.props as { children?: React.ReactNode }).children
|
|
) {
|
|
return React.Children.map(
|
|
(child.props as { children?: React.ReactNode }).children,
|
|
(c) => c,
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<MultiSelectContext.Provider
|
|
value={{
|
|
disabled,
|
|
size: size ?? themeSize,
|
|
radius: radius ?? themeRadius,
|
|
selectedItems,
|
|
setSelectedItems: (items) => setValues(items.map((i) => i.value)),
|
|
isOpen,
|
|
setIsOpen,
|
|
toggleOption,
|
|
registeredItems,
|
|
registerItem,
|
|
}}
|
|
>
|
|
{hiddenRegisterItems}
|
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
selectVariants({
|
|
size: size ?? themeSize,
|
|
className,
|
|
}),
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</Popover>
|
|
</MultiSelectContext.Provider>
|
|
);
|
|
},
|
|
);
|
|
|
|
interface MultiSelectTriggerProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
asChild?: boolean;
|
|
}
|
|
|
|
const selectTriggerVariants = cva('select-trigger', {
|
|
variants: {
|
|
size: {
|
|
small: 'select-trigger-small',
|
|
medium: 'select-trigger-medium',
|
|
large: 'select-trigger-large',
|
|
},
|
|
radius: {
|
|
small: 'select-trigger-radius-small',
|
|
medium: 'select-trigger-radius-medium',
|
|
large: 'select-trigger-radius-large',
|
|
},
|
|
},
|
|
defaultVariants: { size: undefined, radius: undefined },
|
|
});
|
|
|
|
const MultiSelectTrigger = React.forwardRef<
|
|
HTMLButtonElement,
|
|
MultiSelectTriggerProps
|
|
>(function MultiSelectTrigger({ asChild, ...props }, ref) {
|
|
const Comp = asChild ? Slot : MultiSelectTriggerButton;
|
|
|
|
return (
|
|
<PopoverTrigger asChild>
|
|
<Comp ref={ref} {...props} />
|
|
</PopoverTrigger>
|
|
);
|
|
});
|
|
|
|
interface MultiSelectTriggerButtonProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
const MultiSelectTriggerButton = React.forwardRef<
|
|
HTMLButtonElement,
|
|
MultiSelectTriggerButtonProps
|
|
>(function MultiSelectTrigger({ className, children, ...props }, ref) {
|
|
const { size, radius, disabled } = useMultiSelectContext();
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
aria-haspopup="listbox"
|
|
className={cn(selectTriggerVariants({ size, radius, className }))}
|
|
data-placeholder
|
|
disabled={disabled}
|
|
{...props}
|
|
>
|
|
<Slottable>{children}</Slottable>
|
|
<Icon name="RiArrowDownSLine" size={ICON_SIZE[size]} />
|
|
</button>
|
|
);
|
|
});
|
|
|
|
interface MultiSelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
maxHeight?: string;
|
|
}
|
|
|
|
const MultiSelectContent = React.forwardRef<
|
|
HTMLDivElement,
|
|
MultiSelectContentProps
|
|
>(function MultiSelectContent(
|
|
{ className, children, maxHeight = 'auto', ...props },
|
|
ref,
|
|
) {
|
|
return (
|
|
<PopoverContent
|
|
ref={ref}
|
|
className={cn('select-content', className)}
|
|
{...props}
|
|
>
|
|
<div className={cn('select-viewport')}>
|
|
<ScrollArea maxHeight={maxHeight}>
|
|
{children}
|
|
<ScrollBar />
|
|
</ScrollArea>
|
|
</div>
|
|
</PopoverContent>
|
|
);
|
|
});
|
|
|
|
const selectItemVariants = cva('select-item', {
|
|
variants: { check: { left: 'select-item-left', right: 'select-item-right' } },
|
|
defaultVariants: { check: 'left' },
|
|
});
|
|
|
|
const selectItemCheckVariants = cva('select-item-check', {
|
|
variants: {
|
|
check: { left: 'select-item-check-left', right: 'select-item-check-right' },
|
|
},
|
|
defaultVariants: { check: 'left' },
|
|
});
|
|
|
|
interface MultiSelectItemProps {
|
|
value: string;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
const MultiSelectItem = React.forwardRef<HTMLDivElement, MultiSelectItemProps>(
|
|
function MultiSelectItem({ value, children, className }, ref) {
|
|
const { selectedItems, toggleOption, registerItem } =
|
|
useMultiSelectContext();
|
|
const isSelected = selectedItems.some((item) => item.value === value);
|
|
|
|
// Register this item on mount
|
|
React.useEffect(() => {
|
|
registerItem({ value, label: children });
|
|
}, [value, children, registerItem]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
tabIndex={0}
|
|
className={cn(
|
|
selectItemVariants({
|
|
check: typeof children === 'string' ? 'left' : 'right',
|
|
className,
|
|
}),
|
|
)}
|
|
onClick={() => toggleOption({ value, label: children })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ')
|
|
toggleOption({ value, label: children });
|
|
}}
|
|
>
|
|
{isSelected && (
|
|
<span
|
|
className={cn(
|
|
selectItemCheckVariants({
|
|
check: typeof children === 'string' ? 'left' : 'right',
|
|
}),
|
|
)}
|
|
>
|
|
<Icon name="RiCheckLine" size={16} />
|
|
</span>
|
|
)}
|
|
<Slottable>{children}</Slottable>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
MultiSelectItem.displayName = 'MultiSelectItem';
|
|
|
|
const MultiSelectValue = React.forwardRef<
|
|
HTMLSpanElement,
|
|
{ placeholder?: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>
|
|
>(function MultiSelectValue({ placeholder, ...props }, ref) {
|
|
const { selectedItems, size } = useMultiSelectContext();
|
|
|
|
if (selectedItems.length === 0) {
|
|
return (
|
|
<span ref={ref} {...props}>
|
|
{placeholder}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span ref={ref} {...props}>
|
|
{selectedItems.map((item) => (
|
|
<Tag
|
|
key={item.value}
|
|
variant="outline"
|
|
size={size}
|
|
className="select-tag"
|
|
>
|
|
{item.label}
|
|
</Tag>
|
|
))}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
export {
|
|
MultiSelect,
|
|
MultiSelectTrigger,
|
|
MultiSelectTriggerButton,
|
|
MultiSelectContent,
|
|
MultiSelectItem,
|
|
MultiSelectValue,
|
|
};
|