first commit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user