Files
Q-A_test/packages/ufb-react/src/components/multi-select.tsx
T
SDI 12e4f17b62
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s
first commit
2026-07-15 18:05:12 +09:00

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,
};