/** * @title Select * @description 옵션 목록에서 값을 골라 Form 에 제출할 때 사용하는 컴포넌트예요. */'use client';import * as React from 'react';import { Select as SelectPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { CheckIcon, ChevronDownIcon } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: select.yaml, select-trigger.yaml, select-item.yamltype SelectSize = 'large' | 'medium' | 'responsive';const SelectContext = React.createContext<{ size: SelectSize; invalid: boolean; readOnly: boolean }>({ size: 'large', invalid: false, readOnly: false,});/* ------------------------------------------------------------------------------------------------- * Root * -----------------------------------------------------------------------------------------------*/export interface SelectRootProps extends React.ComponentProps<typeof SelectPrimitive.Root> { /** * `responsive` 는 `lg` 미만에서 large, 이상에서 medium 으로 적용돼요. * @default 'large' */ size?: SelectSize; /** Form 검증 실패 상태예요. Trigger 테두리가 Critical 색으로 강조돼요. */ invalid?: boolean; /** 값을 보여주지만 바꿀 수 없어요. */ readOnly?: boolean;}function SelectRoot({ size = 'large', invalid = false, readOnly = false, open, onOpenChange, ...props}: SelectRootProps) { return ( <SelectContext.Provider value={{ size, invalid, readOnly }}> <SelectPrimitive.Root data-slot="select" open={readOnly ? false : open} onOpenChange={readOnly ? undefined : onOpenChange} {...props} /> </SelectContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * Trigger * -----------------------------------------------------------------------------------------------*/const selectTriggerVariants = cva( [ 'group/select-trigger relative isolate box-border flex w-full cursor-pointer items-center bg-bg-transparent antialiased', 'shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', 'transition-[background-color,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', // invalid stroke: 2px stroke fades in above the 1px rest stroke "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border-2 after:border-transparent after:content-['']", 'after:transition-[border-color] after:duration-100 after:ease-easing', 'aria-invalid:after:border-stroke-critical-solid', // pressed (scaleScope: content) 'not-data-[readonly]:enabled:active:bg-bg-transparent-pressed', '[&>*]:transition-[scale] [&>*]:duration-(--rb-duration-pressed-scale) [&>*]:ease-pressed-scale', 'not-data-[readonly]:enabled:active:[&>*]:scale-[0.97]', // readonly 'data-[readonly]:cursor-default data-[readonly]:bg-bg-disabled', // disabled 'disabled:cursor-not-allowed disabled:bg-bg-disabled', // value / placeholder '[&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:grow [&_[data-slot=select-value]]:truncate [&_[data-slot=select-value]]:text-start', '[&_[data-slot=select-value]]:pointer-events-none [&_[data-slot=select-value]]:font-regular [&_[data-slot=select-value]]:text-fg-neutral', 'data-[placeholder]:[&_[data-slot=select-value]]:text-fg-placeholder', 'disabled:[&_[data-slot=select-value]]:text-fg-disabled', // icons '[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:text-fg-neutral-muted disabled:[&_svg]:text-fg-disabled', ], { variants: { size: { large: 'h-x13 gap-x2_5 rounded-r3 px-x4 text-t5 [&_svg]:size-x5', medium: 'h-x10 gap-x2 rounded-r2 px-x3_5 text-t4 [&_svg]:size-x4', responsive: 'h-x13 gap-x2_5 rounded-r3 px-x4 text-t5 [&_svg]:size-x5 lg:h-x10 lg:gap-x2 lg:rounded-r2 lg:px-x3_5 lg:text-t4 lg:[&_svg]:size-x4', }, }, defaultVariants: { size: 'large' }, },);export interface SelectTriggerProps extends Omit<React.ComponentProps<typeof SelectPrimitive.Trigger>, 'children'> { placeholder?: React.ReactNode; prefixIcon?: React.ReactNode; /** @default <ChevronDownIcon /> */ suffixIcon?: React.ReactNode;}function SelectTrigger({ className, placeholder, prefixIcon, suffixIcon, ...props }: SelectTriggerProps) { const { size, invalid, readOnly } = React.useContext(SelectContext); return ( <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} data-readonly={readOnly || undefined} aria-invalid={invalid || undefined} aria-readonly={readOnly || undefined} className={cn(selectTriggerVariants({ size }), className)} {...props} > {prefixIcon && ( <span data-slot="select-prefix-icon" className="inline-flex shrink-0" aria-hidden> {prefixIcon} </span> )} <span data-slot="select-value"> <SelectPrimitive.Value placeholder={placeholder} /> </span> <SelectPrimitive.Icon asChild> <span data-slot="select-suffix-icon" className="inline-flex shrink-0"> {suffixIcon ?? ( <ChevronDownIcon className="transition-transform duration-(--rb-duration-d2) ease-easing group-data-[state=open]/select-trigger:rotate-180 group-data-[state=open]/select-trigger:duration-(--rb-duration-d3)" /> )} </span> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> );}/* ------------------------------------------------------------------------------------------------- * Content * -----------------------------------------------------------------------------------------------*/export interface SelectContentProps extends React.ComponentProps<typeof SelectPrimitive.Content> { /** 포털 컨테이너예요. */ container?: React.ComponentProps<typeof SelectPrimitive.Portal>['container'];}function SelectContent({ className, children, container, position = 'popper', sideOffset = 8, collisionPadding = 8, ...props}: SelectContentProps) { return ( <SelectPrimitive.Portal container={container}> <SelectPrimitive.Content data-slot="select-content" position={position} sideOffset={sideOffset} collisionPadding={collisionPadding} className={cn( 'relative z-50 origin-(--radix-select-content-transform-origin) overflow-hidden rounded-r5 bg-bg-layer-floating shadow-s3 outline-none', position === 'popper' && 'max-h-[min(480px,var(--radix-select-content-available-height))] w-(--radix-select-trigger-width)', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', className, )} {...props} > <SelectPrimitive.Viewport data-slot="select-viewport" className="box-border flex flex-col gap-x2 py-x2" > {children} </SelectPrimitive.Viewport> </SelectPrimitive.Content> </SelectPrimitive.Portal> );}/* ------------------------------------------------------------------------------------------------- * Group * -----------------------------------------------------------------------------------------------*/const selectGroupLabelVariants = cva('text-fg-neutral-subtle', { variants: { size: { large: 'px-x4 py-x2_5 text-t4 font-medium', medium: 'px-x4 py-x2 text-t3 font-regular', responsive: 'px-x4 py-x2_5 text-t4 font-medium lg:py-x2 lg:text-t3 lg:font-regular', }, }, defaultVariants: { size: 'large' },});export interface SelectGroupProps extends React.ComponentProps<typeof SelectPrimitive.Group> { label?: React.ReactNode;}function SelectGroup({ className, label, children, ...props }: SelectGroupProps) { const { size } = React.useContext(SelectContext); return ( <SelectPrimitive.Group data-slot="select-group" className={cn( 'flex flex-col', // divider between groups "[&+&]:before:mx-x4 [&+&]:before:mb-x2 [&+&]:before:block [&+&]:before:h-px [&+&]:before:shrink-0 [&+&]:before:bg-stroke-neutral-muted [&+&]:before:content-['']", className, )} {...props} > {label && ( <SelectPrimitive.Label data-slot="select-group-label" className={selectGroupLabelVariants({ size })}> {label} </SelectPrimitive.Label> )} {children} </SelectPrimitive.Group> );}/* ------------------------------------------------------------------------------------------------- * Item * -----------------------------------------------------------------------------------------------*/const selectItemVariants = cva( [ 'relative isolate m-0 flex cursor-default scroll-my-x2 items-center px-x4 text-start outline-none select-none', "before:absolute before:inset-0 before:-z-10 before:rounded-r3 before:content-['']", 'before:transition-[background-color,inset-inline] before:duration-(--rb-duration-color-transition) before:ease-easing', 'not-data-[disabled]:data-[highlighted]:before:inset-x-x2 not-data-[disabled]:data-[highlighted]:before:bg-bg-transparent-pressed', 'not-data-[disabled]:active:before:inset-x-x2 not-data-[disabled]:active:before:bg-bg-transparent-pressed', '[&>*]:transition-[scale] [&>*]:duration-(--rb-duration-pressed-scale) [&>*]:ease-pressed-scale', 'not-data-[disabled]:active:[&>*]:scale-[0.97]', 'data-[disabled]:cursor-not-allowed', ], { variants: { size: { large: 'gap-x3 py-x3', medium: 'gap-x2 py-x2_5', responsive: 'gap-x3 py-x3 lg:gap-x2 lg:py-x2_5', }, }, defaultVariants: { size: 'large' }, },);const itemPrefixIconSize = { large: 'size-[22px]', medium: 'size-x4_5', responsive: 'size-[22px] lg:size-x4_5',} as const;const itemLabelSize = { large: 'text-t5', medium: 'text-t4', responsive: 'text-t5 lg:text-t4' } as const;const itemDescriptionSize = { large: 'text-t3', medium: 'text-t2', responsive: 'text-t3 lg:text-t2' } as const;const itemIndicatorSize = { large: 'size-x3_5', medium: 'size-x3', responsive: 'size-x3_5 lg:size-x3' } as const;export interface SelectItemProps extends Omit<React.ComponentProps<typeof SelectPrimitive.Item>, 'children'> { label: React.ReactNode; description?: React.ReactNode; prefixIcon?: React.ReactNode;}function SelectItem({ className, label, description, prefixIcon, ...props }: SelectItemProps) { const { size } = React.useContext(SelectContext); return ( <SelectPrimitive.Item data-slot="select-item" className={cn(selectItemVariants({ size }), className)} {...props}> {prefixIcon && ( <span data-slot="select-item-prefix-icon" aria-hidden className={cn( 'inline-flex shrink-0 text-fg-neutral in-data-[disabled]:text-fg-disabled [&_svg]:size-full', itemPrefixIconSize[size], )} > {prefixIcon} </span> )} <span data-slot="select-item-body" className="flex min-w-0 grow flex-col gap-x0_5"> <span data-slot="select-item-label" className={cn('font-regular text-fg-neutral in-data-[disabled]:text-fg-disabled', itemLabelSize[size])} > <SelectPrimitive.ItemText>{label}</SelectPrimitive.ItemText> </span> {description && ( <span data-slot="select-item-description" className={cn( 'font-regular text-fg-neutral-subtle in-data-[disabled]:text-fg-disabled', itemDescriptionSize[size], )} > {description} </span> )} </span> <span data-slot="select-item-indicator" className={cn('inline-flex shrink-0 text-fg-neutral in-data-[disabled]:text-fg-disabled', itemIndicatorSize[size])} > <SelectPrimitive.ItemIndicator className="contents"> <CheckIcon className="size-full" strokeWidth={3.5} /> </SelectPrimitive.ItemIndicator> </span> </SelectPrimitive.Item> );}export { SelectRoot, SelectTrigger, SelectContent, SelectGroup, SelectItem, selectTriggerVariants };