/** * @title Tag Group * @description 여러 태그를 한 묶음으로 배치하고 시작·끝 정렬을 정해요. */import * as React from 'react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/tag-group 스펙: flex + wrap, 너비 100%, 정렬만 바뀐다 (간격은 각 태그의 4px 여백)// platform-dst-motion-uplift-labels ON: 자식이 모두 Steel Tag 면 모션 경계(ExitingPersistence)를 둬서// 나중에 추가된 태그는 펼쳐지며 들어오고, 목록에서 빠진 태그는 접히며 나가요.type TagGroupMotionValue = { isGroupMounted: () => boolean; exit?: { onFinish: () => void };};// tag.tsx 와 같은 전역 심볼로 컨텍스트·표시를 공유해요 (서로 import 하지 않아요)const TAG_GROUP_MOTION_KEY = Symbol.for('@steel/tag-group-motion');const TAG_MOTION_CAPABLE = Symbol.for('@steel/tag/motion-capable');function getTagGroupMotionContext(): React.Context<TagGroupMotionValue | null> { const store = globalThis as unknown as Record<symbol, React.Context<TagGroupMotionValue | null> | undefined>; return (store[TAG_GROUP_MOTION_KEY] ??= React.createContext<TagGroupMotionValue | null>(null));}const isMotionCapableTag = (child: React.ReactNode): child is React.ReactElement => { if (!React.isValidElement(child)) return false; const type = child.type as unknown; return ( (typeof type === 'function' || (typeof type === 'object' && type !== null)) && Reflect.get(type as object, TAG_MOTION_CAPABLE) === true );};type ExitingChild = { key: string; element: React.ReactElement; index: number };/** 빠진 자식을 나가는 모션이 끝날 때까지 원래 자리에 남겨둬요. */function useExitingChildren(children: React.ReactElement[], enabled: boolean) { const previousRef = React.useRef<React.ReactElement[]>([]); const [exiting, setExiting] = React.useState<ExitingChild[]>([]); const keysSignature = children.map((child) => String(child.key)).join('\u0000'); React.useLayoutEffect(() => { const previous = previousRef.current; previousRef.current = children; if (!enabled) return; const nextKeys = new Set(children.map((child) => String(child.key))); const removed = previous .map((element, index) => ({ key: String(element.key), element, index })) .filter((item) => !nextKeys.has(item.key)); if (removed.length) { setExiting((current) => [...current.filter((item) => !nextKeys.has(item.key)), ...removed]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [keysSignature, enabled]); // 같은 키가 다시 들어오면 나가는 목록에서 빼요 const visibleExiting = exiting.filter((item) => !children.some((child) => String(child.key) === item.key)); const finish = React.useCallback((key: string) => { setExiting((current) => current.filter((item) => item.key !== key)); }, []); return { exiting: visibleExiting, finish };}export interface TagGroupProps extends Omit<React.ComponentProps<'div'>, 'children'> { /** 태그를 시작(start) 또는 끝(end)에 정렬해요. */ alignment?: 'start' | 'end'; /** 묶을 태그들 */ children: React.ReactNode; /** 그룹의 접근성 이름 (aria-label). label 이나 titleId 중 하나는 꼭 지정하세요. */ label?: string; /** 그룹 제목 요소의 id (aria-labelledby) */ titleId?: string;}function TagGroup({ alignment = 'start', label, titleId, className, children, ...props }: TagGroupProps) { const MotionContext = getTagGroupMotionContext(); const childArray = React.Children.toArray(children); const shouldProvideMotionBoundary = childArray.length > 0 && childArray.every(isMotionCapableTag); const elements = shouldProvideMotionBoundary ? (childArray as React.ReactElement[]) : []; const mountedRef = React.useRef(false); React.useEffect(() => { mountedRef.current = true; }, []); const isGroupMounted = React.useCallback(() => mountedRef.current, []); const baseValue = React.useMemo<TagGroupMotionValue>(() => ({ isGroupMounted }), [isGroupMounted]); const { exiting, finish } = useExitingChildren(elements, shouldProvideMotionBoundary); let content: React.ReactNode = children; if (shouldProvideMotionBoundary) { const rendered: Array<{ key: string; element: React.ReactElement; exit?: () => void }> = elements.map((element) => ({ key: String(element.key), element, })); [...exiting] .sort((a, b) => a.index - b.index) .forEach((item) => rendered.splice(Math.min(item.index, rendered.length), 0, { key: item.key, element: item.element, exit: () => finish(item.key), }), ); content = rendered.map(({ key, element, exit }) => ( <MotionContext.Provider key={key} value={exit ? { isGroupMounted, exit: { onFinish: exit } } : baseValue}> {element} </MotionContext.Provider> )); } return ( <div role="group" data-slot="tag-group" data-alignment={alignment} aria-label={label} aria-labelledby={titleId} className={cn('flex w-full flex-wrap', alignment === 'end' ? 'justify-end' : 'justify-start', className)} {...props} > {content} </div> );}export { TagGroup };