{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-group","title":"Tag Group","description":"React Aria tag collection with controlled or uncontrolled selection, accessible removal and focus recovery. Search terms: tags, labels.","dependencies":["lucide-react@1.18.0","motion@12.43.0","react-aria-components@1.20.0"],"registryDependencies":["@siteplane/base","@siteplane/badge","@siteplane/button","@siteplane/motion"],"files":[{"path":"packages/ui/src/components/tag-group/tag-group.tsx","content":"\"use client\";\n\nimport { XIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport * as React from \"react\";\nimport {\n  Button as TagButtonPrimitive,\n  CollectionRendererContext,\n  DefaultCollectionRenderer,\n  ListStateContext,\n  Tag as TagPrimitive,\n  TagGroup as TagGroupPrimitive,\n  TagList as TagListPrimitive,\n  type CollectionRenderer,\n  type Selection,\n} from \"react-aria-components\";\n\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport { Badge } from \"../badge\";\nimport { Button } from \"../button\";\nimport { MotionItem, MotionList } from \"../motion\";\nimport { leavingTagProps } from \"./tag-group-leaving\";\n\nconst MotionTagPrimitive = motion.create(TagPrimitive);\n\ntype InternalTagProps = TagProps & { collectionKey?: string };\n\ntype TagEntry = {\n  element: React.ReactElement<TagProps>;\n  key: string;\n};\n\n/**\n * A tag is selectable with an `onSelectedChange` handler or, uncontrolled,\n * with `defaultSelected`; `selected` alone only shows the state.\n */\nfunction isSelectableTag({ element }: TagEntry): boolean {\n  return (\n    Boolean(element.props.onSelectedChange) ||\n    element.props.defaultSelected !== undefined\n  );\n}\n\ntype TagVisualContextValue = {\n  groupRef: React.RefObject<HTMLDivElement | null>;\n  size: SiteplaneControlSize;\n};\n\nconst TagVisualContext = React.createContext<TagVisualContextValue | null>(\n  null,\n);\n\nfunction getTagEntries(children: React.ReactNode): TagEntry[] {\n  const entries: TagEntry[] = [];\n\n  function collect(nodes: React.ReactNode, path: string) {\n    React.Children.forEach(nodes, (child, index) => {\n      if (!React.isValidElement(child)) return;\n      const position = `${path}${index}`;\n      if (child.type === React.Fragment) {\n        collect(\n          (child as React.ReactElement<{ children?: React.ReactNode }>).props\n            .children,\n          `${position}.`,\n        );\n        return;\n      }\n      if (child.type !== Tag) return;\n      const element = child as React.ReactElement<TagProps>;\n      entries.push({\n        element,\n        key: String(element.key ?? element.props.id ?? position),\n      });\n    });\n  }\n\n  collect(children, \"\");\n  return entries;\n}\n\nfunction getTextValue(children: React.ReactNode): string {\n  return React.Children.toArray(children)\n    .map((child) =>\n      typeof child === \"string\" || typeof child === \"number\"\n        ? String(child)\n        : React.isValidElement<{ children?: React.ReactNode }>(child)\n          ? getTextValue(child.props.children)\n          : \"\",\n    )\n    .join(\" \")\n    .trim();\n}\n\ntype CollectionRootProps = React.ComponentProps<\n  CollectionRenderer[\"CollectionRoot\"]\n>;\ntype TagNode =\n  CollectionRootProps[\"collection\"] extends Iterable<infer Node> ? Node : never;\n\n/**\n * A tag that has left the collection, drawn as it was. React Aria drops a\n * removed tag at once, so its focus moves on and its element goes as it always\n * did; this copy is only the picture that fades out and takes no input.\n */\nfunction LeavingTag({ node }: { node: TagNode }): React.ReactElement {\n  return (\n    <div {...leavingTagProps(node.props as Record<string, unknown>)}>\n      {node.rendered}\n    </div>\n  );\n}\n\n/** A tag of the collection, or the picture of one that has just left it. */\nfunction TagSlot({ node }: { node: TagNode }): React.ReactNode {\n  const state = React.useContext(ListStateContext);\n  return state?.collection.getItem(node.key) ? (\n    node.render?.(node)\n  ) : (\n    <LeavingTag node={node} />\n  );\n}\n\n/**\n * Draws the collection through the motion component: a tag that joins fades in\n * and a tag that leaves fades out, the tags around them glide to their new\n * place, and the height of the group follows when a row of tags appears or\n * goes. The tags themselves stay React Aria's rows.\n */\nfunction TagCollectionRoot({\n  collection,\n}: CollectionRootProps): React.ReactElement {\n  const context = React.useContext(TagVisualContext);\n\n  return (\n    // The list adds no box: the group is the box whose height follows.\n    <MotionList\n      className=\"contents\"\n      container={context?.groupRef}\n      layout=\"flow\"\n      render={<div role=\"presentation\" />}\n    >\n      {[...collection].map((node) => (\n        <MotionItem\n          className=\"flex max-w-full min-w-0\"\n          key={node.key}\n          render={<div />}\n        >\n          <TagSlot node={node} />\n        </MotionItem>\n      ))}\n    </MotionList>\n  );\n}\n\nconst tagCollectionRenderer: CollectionRenderer = {\n  ...DefaultCollectionRenderer,\n  CollectionRoot: TagCollectionRoot,\n};\n\nexport interface TagGroupProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"role\"\n> {\n  size?: SiteplaneControlSize;\n}\n\nexport function TagGroup({\n  children,\n  className,\n  ref,\n  size,\n  tabIndex = -1,\n  ...props\n}: TagGroupProps): React.ReactElement {\n  const entries = React.useMemo(() => getTagEntries(children), [children]);\n  const resolvedSize = useResolvedControlSize(size);\n  const groupRef = React.useRef<HTMLDivElement>(null);\n\n  React.useImperativeHandle(ref, () => groupRef.current as HTMLDivElement);\n\n  // An uncontrolled tag (`defaultSelected`, no `selected`) keeps its state\n  // here once it has changed; until then its default applies, also for a tag\n  // that joins later.\n  const [uncontrolledSelection, setUncontrolledSelection] = React.useState<\n    ReadonlyMap<string, boolean>\n  >(() => new Map());\n  const isSelected = ({ element, key }: TagEntry): boolean =>\n    element.props.selected ??\n    uncontrolledSelection.get(key) ??\n    element.props.defaultSelected ??\n    false;\n  const selectableEntries = entries.filter(isSelectableTag);\n  const removableEntries = entries.filter(({ element }) =>\n    Boolean(element.props.onRemove),\n  );\n  const selectedKeys = entries.filter(isSelected).map(({ key }) => key);\n  const disabledKeys = entries\n    .filter(({ element }) => element.props.disabled)\n    .map(({ key }) => key);\n  const visualContext = React.useMemo<TagVisualContextValue>(\n    () => ({ groupRef, size: resolvedSize }),\n    [resolvedSize],\n  );\n\n  const handleSelectionChange = (selection: Selection) => {\n    if (selection === \"all\") return;\n    const changed = new Map<string, boolean>();\n    for (const entry of selectableEntries) {\n      const nextSelected = selection.has(entry.key);\n      if (nextSelected === isSelected(entry)) continue;\n      if (entry.element.props.selected === undefined) {\n        changed.set(entry.key, nextSelected);\n      }\n      entry.element.props.onSelectedChange?.(nextSelected);\n    }\n    if (changed.size > 0) {\n      setUncontrolledSelection((current) => new Map([...current, ...changed]));\n    }\n  };\n\n  const handleRemove = (keys: Set<React.Key>) => {\n    for (const key of keys) {\n      entries\n        .find((entry) => entry.key === String(key))\n        ?.element.props.onRemove?.();\n    }\n  };\n\n  return (\n    <TagVisualContext.Provider value={visualContext}>\n      <TagGroupPrimitive\n        {...({\n          ...props,\n          tabIndex,\n        } as unknown as React.ComponentProps<typeof TagGroupPrimitive>)}\n        className={cn(\n          \"sp-tag-group sp-base-tag-group\",\n          \"relative flex flex-wrap gap-2\",\n          className,\n        )}\n        data-foundation=\"tag-group\"\n        data-size={resolvedSize}\n        data-slot=\"tag-group\"\n        disabledKeys={disabledKeys}\n        {...(removableEntries.length > 0 ? { onRemove: handleRemove } : {})}\n        {...(selectableEntries.length > 0\n          ? { onSelectionChange: handleSelectionChange }\n          : {})}\n        ref={groupRef}\n        selectedKeys={selectedKeys}\n        selectionBehavior=\"toggle\"\n        selectionMode={selectableEntries.length > 0 ? \"multiple\" : \"none\"}\n      >\n        <CollectionRendererContext.Provider value={tagCollectionRenderer}>\n          <TagListPrimitive className=\"contents data-empty:block data-empty:outline-none\">\n            {entries.map((entry) =>\n              React.cloneElement(entry.element, {\n                collectionKey: entry.key,\n                key: entry.key,\n                selected: isSelected(entry),\n              } as InternalTagProps & React.Attributes),\n            )}\n          </TagListPrimitive>\n        </CollectionRendererContext.Provider>\n      </TagGroupPrimitive>\n    </TagVisualContext.Provider>\n  );\n}\n\nexport interface TagProps extends Omit<\n  React.ComponentProps<typeof motion.span>,\n  | \"children\"\n  | \"layout\"\n  | \"layoutDependency\"\n  | \"onClick\"\n  | \"role\"\n  | \"tabIndex\"\n  | \"transition\"\n> {\n  children: React.ReactNode;\n  /** Uncontrolled initial selection; the tag is selectable without a handler. */\n  defaultSelected?: boolean | undefined;\n  disabled?: boolean | undefined;\n  onRemove?: (() => void) | undefined;\n  onSelectedChange?: ((selected: boolean) => void) | undefined;\n  removeLabel?: string | undefined;\n  /** Controlled selection. Omit for uncontrolled usage with `defaultSelected`. */\n  selected?: boolean | undefined;\n}\n\nexport function Tag({\n  children,\n  className,\n  collectionKey,\n  defaultSelected,\n  disabled = false,\n  onRemove,\n  onSelectedChange,\n  removeLabel = \"Remove tag\",\n  selected = false,\n  ...props\n}: InternalTagProps): React.ReactElement {\n  const context = React.useContext(TagVisualContext);\n  if (!context || collectionKey === undefined) {\n    throw new Error(\"Tag must be rendered inside TagGroup.\");\n  }\n\n  const selectable = Boolean(onSelectedChange) || defaultSelected !== undefined;\n  const removable = Boolean(onRemove);\n\n  return (\n    <Badge\n      {...(props as unknown as React.ComponentProps<typeof Badge>)}\n      className={cn(\n        \"sp-tag group\",\n        // The tag renders a div, so Badge's :disabled styles never match it.\n        \"data-disabled:opacity-(--sp-rw-disabled-opacity-action)\",\n        disabled ? \"cursor-not-allowed\" : selectable && \"cursor-pointer\",\n        className,\n      )}\n      data-disabled={disabled || undefined}\n      data-removable={removable || undefined}\n      data-selected={selected || undefined}\n      data-slot=\"tag\"\n      render={\n        <MotionTagPrimitive\n          id={collectionKey}\n          isDisabled={disabled}\n          textValue={getTextValue(children)}\n        />\n      }\n      size={context.size}\n      variant={selected ? \"info\" : \"secondary\"}\n    >\n      {children}\n      {removable ? (\n        <Button\n          aria-label={removeLabel}\n          // A disabled tag already dims its remove action; it isn't dimmed\n          // again (design-rework.css, \"Disabled parts\").\n          className=\"sp-tag-remove-action size-3.5 min-w-0 shrink-0\"\n          disabled={disabled}\n          render={<TagButtonPrimitive slot=\"remove\" />}\n          size=\"icon-2xs\"\n          variant=\"ghost\"\n        >\n          <XIcon aria-hidden=\"true\" className=\"size-2.5\" />\n        </Button>\n      ) : null}\n    </Badge>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/tag-group/tag-group.tsx"},{"path":"packages/ui/src/components/tag-group/tag-group-leaving.ts","content":"/**\n * What a tag that has left the collection still shows while it fades out: the\n * classes and data attributes React Aria was given for it. Everything that\n * makes a tag interactive is left out, because the tag is gone.\n */\nexport function leavingTagProps(props: Record<string, unknown>): {\n  className: string | undefined;\n} & Record<string, unknown> {\n  const kept: Record<string, unknown> = {};\n  for (const [name, value] of Object.entries(props))\n    if (name.startsWith(\"data-\")) kept[name] = value;\n  return {\n    ...kept,\n    className:\n      typeof props.className === \"string\" ? props.className : undefined,\n  };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/tag-group/tag-group-leaving.ts"},{"path":"packages/ui/src/components/tag-group/index.ts","content":"export * from \"./tag-group\";\n","type":"registry:ui","target":"~/src/siteplane/components/tag-group/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/tag-group.md","categories":["Data display"],"meta":{"avoidWhen":["Avoid when every item is a persistent toggle and removal is not part of the collection workflow."],"description":"React Aria tag collection with controlled or uncontrolled selection, accessible removal and focus recovery.","knownLimitations":["Give every Tag a stable key. An index as the key makes a removed tag leave as its neighbor.","A tag that has been removed stays on screen as a picture, inert and hidden from assistive technology, until it has faded out. Focus has moved on as soon as it is removed.","Each tag sits in a wrapper element inside the grid, so a selector that expects the tags to be direct children of the group does not match."],"providerNotes":["Depends on the motion component, which the registry installs with the TagGroup.","SiteplaneMotionProvider preset=\"reduced\" and the system setting make added and removed tags change at once."],"releaseStatus":"stable","searchTerms":["tag-group","tags","labels","selection","removal"],"useWhen":["Use when a compact visible collection of labels can be selected, removed or both.","Use when labels are added and removed over time and the group should neither jump nor push the content below it."]}}