/** * @title Select Box * @description 명확한 테두리를 가진 카드형 컨테이너로 정의된 목록 중 하나 이상의 옵션을 선택하는 컴포넌트예요. */'use client';import * as React from 'react';import { Checkbox as CheckboxPrimitive, RadioGroup as RadioGroupPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: select-box.yaml, select-box-group.yaml, select-box-checkmark.yaml, radiomark.yamltype SelectBoxLayout = 'horizontal' | 'vertical';type FooterVisibility = 'when-selected' | 'when-not-selected' | 'always';/* ------------------------------------------------------------------------------------------------- * Internal state helpers * -----------------------------------------------------------------------------------------------*/function useControllableState<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) { const [uncontrolled, setUncontrolled] = React.useState<T>(defaultValue); const isControlled = value !== undefined; const current = isControlled ? value : uncontrolled; const setValue = React.useCallback( (next: T) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }, [isControlled, onChange], ); return [current, setValue] as const;}const SelectBoxGroupContext = React.createContext<{ columns: number }>({ columns: 1 });const RadioSelectBoxValueContext = React.createContext<{ value: string; disabled?: boolean } | null>(null);const SelectBoxItemContext = React.createContext<{ checked: boolean; disabled: boolean }>({ checked: false, disabled: false,});/* ------------------------------------------------------------------------------------------------- * Styles * -----------------------------------------------------------------------------------------------*/const selectBoxGroupClassName = 'grid w-full grid-cols-[repeat(var(--select-box-columns,1),minmax(0,1fr))] gap-x-x3 gap-y-spacing-y-component-default data-[multi-column]:auto-rows-fr';const selectBoxRootClassName = cn( 'group/select-box relative flex cursor-pointer flex-col overflow-hidden rounded-r3 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', // selected 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', 'data-[checked]:not-data-[disabled]:after:border-stroke-neutral-contrast', // pressed: whole container 'has-[[data-slot=select-box-trigger]:enabled:active]:bg-bg-transparent-pressed', // disabled 'data-[disabled]:cursor-not-allowed', 'data-[checked]:data-[disabled]:shadow-[inset_0_0_0_2px_var(--rb-color-stroke-neutral-weak)]', // focus ring follows the trigger 'outline-2 outline-offset-2 outline-transparent has-[[data-slot=select-box-trigger]:focus-visible]:outline-stroke-focus-ring',);const selectBoxTriggerVariants = cva( 'flex grow cursor-[inherit] justify-between gap-x1_5 bg-transparent text-start outline-none disabled:cursor-not-allowed', { variants: { layout: { horizontal: 'items-center py-x4 pr-x4 pl-x5', vertical: 'items-start px-x4 py-x5', }, }, defaultVariants: { layout: 'horizontal' }, },);const selectBoxContentVariants = cva( [ 'flex min-w-0 origin-center transition-[scale] duration-(--rb-duration-pressed-scale) ease-pressed-scale', '[[data-slot=select-box-trigger]:enabled:active_&]:scale-[0.97]', '[&_[data-slot=select-box-prefix-icon]]:size-[22px] [&_[data-slot=select-box-prefix-icon]]:shrink-0 [&_[data-slot=select-box-prefix-icon]]:text-fg-neutral', 'group-data-[disabled]/select-box:[&_[data-slot=select-box-prefix-icon]]:text-fg-disabled', ], { variants: { layout: { horizontal: 'items-center gap-x3', vertical: 'flex-col gap-x2_5', }, }, defaultVariants: { layout: 'horizontal' }, },);/* ------------------------------------------------------------------------------------------------- * Shared item body * -----------------------------------------------------------------------------------------------*/interface SelectBoxItemBaseProps { label: React.ReactNode; description?: React.ReactNode; prefixIcon?: React.ReactNode; /** 오른쪽 끝 Control 영역. `CheckSelectBoxCheckmark`, `RadioSelectBoxRadiomark` 또는 자유로운 요소를 넣어요. */ suffix?: React.ReactNode; /** 박스 하단에 펼쳐지는 추가 콘텐츠예요. */ footer?: React.ReactNode; /** @default 'when-selected' */ footerVisibility?: FooterVisibility; /** 그룹의 `columns` 가 1보다 크면 기본값이 `vertical` 이에요. */ layout?: SelectBoxLayout;}function SelectBoxContent({ layout, label, description, prefixIcon,}: Pick<SelectBoxItemBaseProps, 'label' | 'description' | 'prefixIcon'> & { layout: SelectBoxLayout }) { return ( <span data-slot="select-box-content" className={selectBoxContentVariants({ layout })}> {prefixIcon && ( <span data-slot="select-box-prefix-icon" className="inline-flex [&_svg]:size-full" aria-hidden> {prefixIcon} </span> )} <span data-slot="select-box-body" className="mr-auto flex min-w-0 flex-col gap-x0_5"> <span data-slot="select-box-label" className="flex items-center justify-start gap-x1 text-t5 font-medium text-fg-neutral group-data-[disabled]/select-box:text-fg-disabled" > {label} </span> {description && ( <span data-slot="select-box-description" className="text-t3 font-regular text-fg-neutral-muted group-data-[disabled]/select-box:text-fg-disabled" > {description} </span> )} </span> </span> );}function SelectBoxFooter({ open, children }: { open: boolean; children: React.ReactNode }) { return ( <div data-slot="select-box-footer" data-open={open || undefined} inert={!open} className={cn( 'grid grid-rows-[0fr] opacity-0', // closing: height collapses fast, opacity fades slowly '[transition:grid-template-rows_var(--rb-duration-d6)_var(--rb-timing-function-easing),opacity_400ms_var(--rb-timing-function-easing)]', // opening: height expands slowly, opacity fades in fast 'data-[open]:grid-rows-[1fr] data-[open]:opacity-100', 'data-[open]:[transition:grid-template-rows_400ms_var(--rb-timing-function-easing),opacity_var(--rb-duration-d6)_var(--rb-timing-function-easing)]', )} > <div className="min-h-0 overflow-hidden">{children}</div> </div> );}function isFooterOpen(visibility: FooterVisibility, checked: boolean) { if (visibility === 'always') return true; if (visibility === 'when-not-selected') return !checked; return checked;}/* ------------------------------------------------------------------------------------------------- * Check Select Box * -----------------------------------------------------------------------------------------------*/export interface CheckSelectBoxGroupProps extends React.ComponentProps<'div'> { /** 그리드 열 개수예요. 1보다 크면 하위 항목의 layout 이 `vertical` 이 돼요. */ columns?: number;}function CheckSelectBoxGroup({ columns = 1, className, style, ...props }: CheckSelectBoxGroupProps) { return ( <SelectBoxGroupContext.Provider value={{ columns }}> <div role="group" data-slot="select-box-group" data-columns={columns} data-multi-column={columns > 1 || undefined} className={cn(selectBoxGroupClassName, className)} style={{ '--select-box-columns': columns, ...style } as React.CSSProperties} {...props} /> </SelectBoxGroupContext.Provider> );}export interface CheckSelectBoxProps extends Omit<React.ComponentProps<typeof CheckboxPrimitive.Root>, 'children' | 'checked' | 'defaultChecked' | 'onCheckedChange'>, SelectBoxItemBaseProps { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; rootClassName?: string; rootRef?: React.Ref<HTMLDivElement>;}function CheckSelectBox({ label, description, prefixIcon, suffix, footer, footerVisibility = 'when-selected', layout: layoutProp, checked: checkedProp, defaultChecked = false, onCheckedChange, disabled = false, className, rootClassName, rootRef, ...props}: CheckSelectBoxProps) { const { columns } = React.useContext(SelectBoxGroupContext); const layout = layoutProp ?? (columns > 1 ? 'vertical' : 'horizontal'); const [checked, setChecked] = useControllableState(checkedProp, defaultChecked, onCheckedChange); return ( <SelectBoxItemContext.Provider value={{ checked, disabled }}> <div ref={rootRef} data-slot="select-box" data-layout={layout} data-checked={checked || undefined} data-disabled={disabled || undefined} className={cn(selectBoxRootClassName, rootClassName)} > <CheckboxPrimitive.Root data-slot="select-box-trigger" checked={checked} onCheckedChange={(next) => setChecked(next === true)} disabled={disabled} className={cn(selectBoxTriggerVariants({ layout }), className)} {...props} > <SelectBoxContent layout={layout} label={label} description={description} prefixIcon={prefixIcon} /> {suffix} </CheckboxPrimitive.Root> {footer && <SelectBoxFooter open={isFooterOpen(footerVisibility, checked)}>{footer}</SelectBoxFooter>} </div> </SelectBoxItemContext.Provider> );}export interface CheckSelectBoxCheckmarkProps extends React.ComponentProps<'span'> {}function CheckSelectBoxCheckmark({ className, ...props }: CheckSelectBoxCheckmarkProps) { const { checked, disabled } = React.useContext(SelectBoxItemContext); return ( <span data-slot="select-box-checkmark" data-checked={checked || undefined} aria-hidden className={cn('relative box-border size-x5 flex-none', className)} {...props} > <svg viewBox="0 0 24 24" fill="none" className={cn( 'absolute inset-0 m-auto block size-[15px] text-fg-placeholder transition-[color] duration-(--rb-duration-color-transition) ease-easing', !checked && !disabled && '[[data-slot=select-box-trigger]:enabled:active_&]:text-fg-neutral-subtle', checked && 'text-fg-neutral', disabled && 'text-fg-disabled', )} > <path d="M4 12.5 9.5 18 20 6.5" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" /> </svg> </span> );}/* ------------------------------------------------------------------------------------------------- * Radio Select Box * -----------------------------------------------------------------------------------------------*/export interface RadioSelectBoxRootProps extends Omit<React.ComponentProps<typeof RadioGroupPrimitive.Root>, 'value' | 'defaultValue' | 'onValueChange'> { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** 그리드 열 개수예요. 1보다 크면 하위 항목의 layout 이 `vertical` 이 돼요. */ columns?: number;}function RadioSelectBoxRoot({ columns = 1, value: valueProp, defaultValue = '', onValueChange, disabled, className, style, ...props}: RadioSelectBoxRootProps) { const [value, setValue] = useControllableState(valueProp, defaultValue, onValueChange); return ( <SelectBoxGroupContext.Provider value={{ columns }}> <RadioSelectBoxValueContext.Provider value={{ value, disabled }}> <RadioGroupPrimitive.Root data-slot="select-box-group" data-columns={columns} data-multi-column={columns > 1 || undefined} value={value} onValueChange={setValue} disabled={disabled} className={cn(selectBoxGroupClassName, className)} style={{ '--select-box-columns': columns, ...style } as React.CSSProperties} {...props} /> </RadioSelectBoxValueContext.Provider> </SelectBoxGroupContext.Provider> );}export interface RadioSelectBoxItemProps extends Omit<React.ComponentProps<typeof RadioGroupPrimitive.Item>, 'children'>, SelectBoxItemBaseProps { rootClassName?: string; rootRef?: React.Ref<HTMLDivElement>;}function RadioSelectBoxItem({ label, description, prefixIcon, suffix, footer, footerVisibility = 'when-selected', layout: layoutProp, value, disabled: disabledProp, className, rootClassName, rootRef, ...props}: RadioSelectBoxItemProps) { const { columns } = React.useContext(SelectBoxGroupContext); const group = React.useContext(RadioSelectBoxValueContext); const layout = layoutProp ?? (columns > 1 ? 'vertical' : 'horizontal'); const checked = group?.value === value; const disabled = Boolean(disabledProp || group?.disabled); return ( <SelectBoxItemContext.Provider value={{ checked, disabled }}> <div ref={rootRef} data-slot="select-box" data-layout={layout} data-checked={checked || undefined} data-disabled={disabled || undefined} className={cn(selectBoxRootClassName, rootClassName)} > <RadioGroupPrimitive.Item data-slot="select-box-trigger" value={value} disabled={disabledProp} className={cn(selectBoxTriggerVariants({ layout }), className)} {...props} > <SelectBoxContent layout={layout} label={label} description={description} prefixIcon={prefixIcon} /> {suffix} </RadioGroupPrimitive.Item> {footer && <SelectBoxFooter open={isFooterOpen(footerVisibility, checked)}>{footer}</SelectBoxFooter>} </div> </SelectBoxItemContext.Provider> );}export interface RadioSelectBoxRadiomarkProps extends React.ComponentProps<'span'> {}// radiomark.yaml — tone=neutral, size=mediumfunction RadioSelectBoxRadiomark({ className, ...props }: RadioSelectBoxRadiomarkProps) { const { checked, disabled } = React.useContext(SelectBoxItemContext); return ( <span data-slot="select-box-radiomark" data-checked={checked || undefined} data-disabled={disabled || undefined} aria-hidden className={cn( 'relative inline-flex size-x5 flex-none items-center justify-center rounded-full', 'transition-[background-color,box-shadow] duration-(--rb-duration-color-transition) ease-easing', !checked && !disabled && 'shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', !checked && !disabled && '[[data-slot=select-box-trigger]:enabled:active_&]:bg-bg-transparent-pressed', checked && !disabled && 'bg-bg-neutral-solid [[data-slot=select-box-trigger]:enabled:active_&]:bg-bg-neutral-solid-pressed', !checked && disabled && 'bg-palette-gray-300', checked && disabled && 'bg-bg-transparent shadow-[inset_0_0_0_1px_var(--rb-color-palette-gray-300)]', className, )} {...props} > {checked && ( <span className={cn( 'block rounded-full', disabled ? 'size-x2_5 bg-palette-gray-300' : 'size-x2 bg-fg-on-neutral-solid', )} /> )} </span> );}export { CheckSelectBoxGroup, CheckSelectBox, CheckSelectBoxCheckmark, RadioSelectBoxRoot, RadioSelectBoxItem, RadioSelectBoxRadiomark,};