/** * @title List * @description 가로 행으로 구성된 콘텐츠를 연속된 열로 표현하는 컴포넌트예요. */import * as React from 'react';import { Slot } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: list-item.yaml, list-header.yaml/* ------------------------------------------------------------------------------------------------- * List * -----------------------------------------------------------------------------------------------*/export interface ListProps extends React.ComponentProps<'ul'> { asChild?: boolean; /** * 눌림 상태에서 항목 배경에 적용되는 모서리 반경이에요. `r2` 같은 radius 토큰 이름이나 CSS 길이를 받아요. * 카드처럼 모서리가 둥근 컨테이너 안에서 Concentric Radius 를 맞출 때 사용해요. */ itemBorderRadius?: string;}function toRadius(value: string) { return /^r\d/.test(value) ? `var(--radius-${value})` : value;}function List({ className, asChild = false, itemBorderRadius, style, ...props }: ListProps) { const Comp = asChild ? Slot.Root : 'ul'; return ( <Comp data-slot="list" className={cn('m-0 flex w-full list-none flex-col p-0', className)} style={ itemBorderRadius ? ({ '--list-item-border-radius': toRadius(itemBorderRadius), ...style } as React.CSSProperties) : style } {...props} /> );}/* ------------------------------------------------------------------------------------------------- * Slots * -----------------------------------------------------------------------------------------------*/const listItemRootVariants = cva( [ 'relative isolate box-border flex w-full border-none font-[inherit] antialiased', 'px-spacing-x-global-gutter py-x3', ], { variants: { alignItems: { center: 'items-center', flexStart: 'items-start', }, }, defaultVariants: { alignItems: 'center' }, },);// body(content) 슬롯. ::before 가 행 전체를 덮는 배경 레이어(highlighted / pressed),// ::after 가 행 전체 크기의 터치 영역과 포커스 링이에요.const listItemContentBase = [ 'inline-flex grow flex-col items-start gap-x0_5 pr-x2_5 text-start', 'border-none bg-transparent font-[inherit] text-inherit no-underline outline-none', "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']", 'before:transition-[background-color,left,right,border-radius] before:duration-(--rb-duration-color-transition) before:ease-easing', "after:absolute after:inset-0 after:content-['']", 'after:outline-2 after:-outline-offset-2 after:outline-transparent focus-visible:after:outline-stroke-focus-ring', 'after:transition-[outline-color] after:duration-(--rb-duration-color-transition)',];const pressedShape = [ 'hover:not-disabled:before:inset-x-x1_5 active:not-disabled:before:inset-x-x1_5', 'hover:not-disabled:before:rounded-[var(--list-item-border-radius,var(--rb-dimension-x2_5))]', 'active:not-disabled:before:rounded-[var(--list-item-border-radius,var(--rb-dimension-x2_5))]',];function contentClassName({ interactive, highlighted }: { interactive: boolean; highlighted: boolean }) { return cn( listItemContentBase, highlighted && 'before:bg-bg-brand-weak', interactive && [ 'cursor-pointer disabled:cursor-not-allowed', pressedShape, highlighted ? 'hover:not-disabled:before:bg-bg-brand-weak-pressed active:not-disabled:before:bg-bg-brand-weak-pressed' : 'hover:not-disabled:before:bg-bg-transparent-pressed active:not-disabled:before:bg-bg-transparent-pressed', ], );}const affixBase = 'inline-flex shrink-0 items-center';function ListItemPrefix({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="list-item-prefix" className={cn( affixBase, 'pr-x3 [&>svg]:size-[22px] [&>svg]:text-fg-neutral data-[disabled]:[&>svg]:text-fg-disabled', className, )} {...props} /> );}function ListItemSuffix({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="list-item-suffix" className={cn( affixBase, 'gap-x1 text-t5 font-regular text-fg-neutral-subtle', '[&>svg]:size-[18px] [&>svg]:text-fg-neutral-subtle data-[disabled]:[&>svg]:text-fg-disabled', className, )} {...props} /> );}function ListItemTitle({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="list-item-title" className={cn('shrink-0 text-t5 font-regular text-fg-neutral data-[disabled]:text-fg-disabled', className)} {...props} /> );}function ListItemDetail({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="list-item-detail" className={cn('text-t3 font-regular text-fg-neutral-subtle data-[disabled]:text-fg-disabled', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * ListItem (static) * -----------------------------------------------------------------------------------------------*/interface ListItemSharedProps extends VariantProps<typeof listItemRootVariants> { title: React.ReactNode; detail?: React.ReactNode; prefix?: React.ReactNode; suffix?: React.ReactNode; /** 새 알림처럼 주목이 필요한 항목을 Brand Weak 배경으로 강조해요. */ highlighted?: boolean;}export interface ListItemProps extends Omit<React.ComponentProps<'li'>, 'title' | 'prefix' | 'children'>, ListItemSharedProps {}function ListItem({ className, title, detail, prefix, suffix, highlighted = false, alignItems, ...props}: ListItemProps) { return ( <li data-slot="list-item" data-highlighted={highlighted || undefined} className={cn(listItemRootVariants({ alignItems }), className)} {...props} > {prefix && <ListItemPrefix>{prefix}</ListItemPrefix>} <div data-slot="list-item-content" className={contentClassName({ interactive: false, highlighted })}> <ListItemTitle>{title}</ListItemTitle> {detail && <ListItemDetail>{detail}</ListItemDetail>} </div> {suffix && <ListItemSuffix>{suffix}</ListItemSuffix>} </li> );}/* ------------------------------------------------------------------------------------------------- * ListButtonItem / ListLinkItem (clickable) * -----------------------------------------------------------------------------------------------*/interface ClickableRootProps { rootRef?: React.Ref<HTMLLIElement>; rootProps?: React.ComponentProps<'li'>;}export interface ListButtonItemProps extends Omit<React.ComponentProps<'button'>, 'title' | 'prefix' | 'children'>, ListItemSharedProps, ClickableRootProps {}function ListButtonItem({ className, title, detail, prefix, suffix, highlighted = false, alignItems, rootRef, rootProps, disabled, ...props}: ListButtonItemProps) { const state = { 'data-disabled': disabled ? '' : undefined }; return ( <li ref={rootRef} data-slot="list-item" data-highlighted={highlighted || undefined} {...rootProps} className={cn(listItemRootVariants({ alignItems }), rootProps?.className)} > {prefix && <ListItemPrefix {...state}>{prefix}</ListItemPrefix>} <button type="button" data-slot="list-item-content" disabled={disabled} className={cn(contentClassName({ interactive: true, highlighted }), className)} {...props} > <ListItemTitle {...state}>{title}</ListItemTitle> {detail && <ListItemDetail {...state}>{detail}</ListItemDetail>} </button> {suffix && <ListItemSuffix {...state}>{suffix}</ListItemSuffix>} </li> );}export interface ListLinkItemProps extends Omit<React.ComponentProps<'a'>, 'title' | 'prefix' | 'children'>, ListItemSharedProps, ClickableRootProps {}function ListLinkItem({ className, title, detail, prefix, suffix, highlighted = false, alignItems, rootRef, rootProps, ...props}: ListLinkItemProps) { return ( <li ref={rootRef} data-slot="list-item" data-highlighted={highlighted || undefined} {...rootProps} className={cn(listItemRootVariants({ alignItems }), rootProps?.className)} > {prefix && <ListItemPrefix>{prefix}</ListItemPrefix>} <a data-slot="list-item-content" className={cn(contentClassName({ interactive: true, highlighted }), className)} {...props} > <ListItemTitle>{title}</ListItemTitle> {detail && <ListItemDetail>{detail}</ListItemDetail>} </a> {suffix && <ListItemSuffix>{suffix}</ListItemSuffix>} </li> );}/* ------------------------------------------------------------------------------------------------- * ListDivider * -----------------------------------------------------------------------------------------------*/export interface ListDividerProps extends React.ComponentProps<'li'> { /** 좌우에 global gutter 만큼 여백을 둬요. */ inset?: boolean;}function ListDivider({ className, inset = false, ...props }: ListDividerProps) { return ( <li aria-hidden data-slot="list-divider" className={cn('h-0 border-b border-stroke-neutral-muted', inset && 'mx-spacing-x-global-gutter', className)} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * ListHeader * -----------------------------------------------------------------------------------------------*/const listHeaderVariants = cva( 'box-border flex w-full items-center justify-between gap-x2_5 px-spacing-x-global-gutter py-x2 text-t4', { variants: { variant: { mediumWeak: 'font-medium text-fg-neutral-subtle', boldSolid: 'font-bold text-fg-neutral', }, }, defaultVariants: { variant: 'mediumWeak' }, },);export interface ListHeaderProps extends React.ComponentProps<'div'>, VariantProps<typeof listHeaderVariants> { /** 렌더링할 태그예요. 문서 구조에 맞는 제목 태그를 지정해요. */ as?: 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'legend'; asChild?: boolean;}function ListHeader({ className, variant, as = 'div', asChild = false, ...props }: ListHeaderProps) { const Comp = (asChild ? Slot.Root : as) as 'div'; return <Comp data-slot="list-header" className={cn(listHeaderVariants({ variant }), className)} {...props} />;}export { List, ListItem, ListButtonItem, ListLinkItem, ListDivider, ListHeader, ListItemPrefix, ListItemSuffix, ListItemTitle, ListItemDetail, listHeaderVariants,};