{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag",
  "type": "registry:ui",
  "title": "Tag",
  "description": "Removable chips that fold away, close the gap and hand focus on.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/avatar.json"
  ],
  "files": [
    {
      "path": "components/ui/tag.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useRef } from \"react\";\nimport { Avatar } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type TagTone = \"neutral\" | \"success\" | \"warning\" | \"danger\" | \"info\";\n\nconst dotTone: Record<TagTone, string> = {\n  neutral: \"bg-fg-3\",\n  success: \"bg-success\",\n  warning: \"bg-warning\",\n  danger: \"bg-danger\",\n  info: \"bg-info\",\n};\n\nconst GroupContext = createContext(false);\n\n/* -------------------------------------------------------------------------------------------------\n * Focus helpers\n * -----------------------------------------------------------------------------------------------*/\n\n// The one control per tag that arrows move between: the tag itself when it's clickable,\n// otherwise its remove button.\nconst targets = (group: Element) =>\n  Array.from(group.querySelectorAll<HTMLElement>(\"[data-tag]:not([data-removing]) [data-tag-target]\")).filter((el) => !el.closest(\"[data-tag]\")?.hasAttribute(\"data-removing\"));\n\n/** After a tag goes, focus the next one, else the previous, else the group itself. */\nfunction refocusAfter(tag: HTMLElement) {\n  const group = tag.closest<HTMLElement>(\"[data-tag-group]\");\n  if (!group) return;\n  const all = Array.from(group.querySelectorAll<HTMLElement>(\"[data-tag]\"));\n  const at = all.indexOf(tag);\n  tag.setAttribute(\"data-removing\", \"\");\n  const pick = (el: HTMLElement | undefined) => el?.querySelector<HTMLElement>(\"[data-tag-target]\");\n  const rest = all.filter((el) => el !== tag && !el.hasAttribute(\"data-removing\"));\n  const next = pick(rest.find((el) => all.indexOf(el) > at)) ?? pick([...rest].reverse().find((el) => all.indexOf(el) < at));\n  (next ?? group).focus();\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Tag\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TagProps = Omit<React.ComponentProps<\"span\">, \"onClick\"> & {\n  /** Color of the leading dot. Semantic tones are for meaning only. */\n  tone?: TagTone;\n  dot?: boolean;\n  /** A person or team, drawn as a small avatar flush with the left edge. */\n  avatar?: { name: string; src?: string };\n  /** A leading icon at 14px (12px in sm). */\n  icon?: React.ReactNode;\n  size?: \"sm\" | \"md\";\n  /** Shows the remove button, and Backspace or Delete removes the tag. */\n  onRemove?: () => void;\n  /** Accessible name for the remove button. Defaults to \"Remove <label>\". */\n  removeLabel?: string;\n  /** Makes the tag itself a button: open the label, filter by it, edit the value. */\n  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n  /** Makes the tag itself a link. */\n  href?: string;\n  disabled?: boolean;\n  /** Longest the label grows before it truncates, in px. The full text is in its tooltip. */\n  maxWidth?: number;\n};\n\n/**\n * A chip for a label, a person or a filter value. Removable ones leave by shrinking and fading,\n * and inside a TagGroup the rest slide over to close the gap and focus lands on a neighbor.\n */\nexport function Tag({\n  tone = \"neutral\",\n  dot = false,\n  avatar,\n  icon,\n  size = \"md\",\n  onRemove,\n  removeLabel,\n  onClick,\n  href,\n  disabled = false,\n  maxWidth = 200,\n  className,\n  children,\n  ref,\n  ...rest\n}: TagProps) {\n  const reduce = useReducedMotion();\n  const inGroup = useContext(GroupContext);\n  const root = useRef<HTMLSpanElement>(null);\n  const text = typeof children === \"string\" || typeof children === \"number\" ? String(children) : avatar?.name;\n  const interactive = !disabled && (!!onClick || !!href);\n  const removable = !!onRemove && !disabled;\n  const sm = size === \"sm\";\n\n  const remove = () => {\n    if (!onRemove || !root.current) return;\n    // Only move focus if it was inside this tag; a pointer removal elsewhere leaves focus alone.\n    if (root.current.contains(document.activeElement)) refocusAfter(root.current);\n    else root.current.setAttribute(\"data-removing\", \"\");\n    onRemove();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (removable && (e.key === \"Backspace\" || e.key === \"Delete\")) {\n      e.preventDefault();\n      remove();\n    }\n  };\n\n  const lead = avatar ? (\n    <Avatar name={avatar.name} src={avatar.src} size={sm ? 16 : 20} alt=\"\" className=\"-ml-px\" />\n  ) : icon ? (\n    <span aria-hidden className={cn(\"grid shrink-0 place-items-center text-fg-3 [&_svg]:size-full\", sm ? \"size-3\" : \"size-3.5\")}>\n      {icon}\n    </span>\n  ) : dot ? (\n    <span aria-hidden className={cn(\"size-1.5 shrink-0 rounded-full transition-[background-color] duration-200\", dotTone[tone])} />\n  ) : null;\n\n  // Space before the content: tight when an avatar sits flush in the pill's curve.\n  const padStart = avatar ? (sm ? \"pl-0.5\" : \"pl-[3px]\") : sm ? \"pl-2\" : \"pl-2.5\";\n  const padEnd = sm ? \"pr-2\" : \"pr-2.5\";\n\n  const label = (\n    <span className=\"truncate\" style={{ maxWidth }} title={text}>\n      {children ?? avatar?.name}\n    </span>\n  );\n\n  const bodyClass = cn(\"inline-flex h-full min-w-0 items-center gap-1.5 rounded-full outline-none\", padStart, removable ? \"pr-1\" : padEnd);\n\n  const body = interactive ? (\n    href ? (\n      <a data-tag-target href={href} onKeyDown={onKeyDown} className={cn(bodyClass, \"cursor-pointer\")}>\n        {lead}\n        {label}\n      </a>\n    ) : (\n      <button data-tag-target type=\"button\" onClick={onClick} onKeyDown={onKeyDown} className={bodyClass}>\n        {lead}\n        {label}\n      </button>\n    )\n  ) : (\n    <span className={bodyClass}>\n      {lead}\n      {label}\n    </span>\n  );\n\n  return (\n    <motion.span\n      ref={(node: HTMLSpanElement | null) => {\n        root.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      data-tag=\"\"\n      data-size={size}\n      data-interactive={interactive || undefined}\n      data-disabled={disabled || undefined}\n      role={inGroup ? \"listitem\" : undefined}\n      layout={inGroup && !reduce ? \"position\" : undefined}\n      // Tags only animate in when they join a group; a tag rendered on its own is simply there.\n      initial={!inGroup ? false : reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.out } }}\n      transition={reduce ? { duration: 0.12 } : { ...spring.pop, layout: spring.snappy }}\n      className={cn(\n        \"relative inline-flex max-w-full shrink-0 select-none items-center rounded-full border border-line-2 bg-raised align-middle text-fg-2\",\n        sm ? \"h-6 text-[12px]\" : \"h-7 text-[12.5px]\",\n        removable && \"pr-1\",\n        \"transition-[background-color,border-color,color,scale] duration-150\",\n        // A clickable tag lights up as one piece and takes its focus ring around the whole pill.\n        // Only the tag's own body lights it up; hovering the remove button lights just the button.\n        interactive &&\n          \"pointer-fine:has-[[data-tag-target]:hover]:border-fg-4 pointer-fine:has-[[data-tag-target]:hover]:bg-hover pointer-fine:has-[[data-tag-target]:hover]:text-fg has-[[data-tag-target]:active]:scale-[0.97] has-[[data-tag-target]:active]:duration-75\",\n        interactive && \"has-[[data-tag-target]:focus-visible]:outline-solid has-[[data-tag-target]:focus-visible]:outline-1 has-[[data-tag-target]:focus-visible]:outline-offset-2 has-[[data-tag-target]:focus-visible]:outline-fg-3\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.span>)}\n    >\n      {body}\n      {removable && (\n        <button\n          type=\"button\"\n          data-remove=\"\"\n          // In a tag that isn't itself a button, the remove button is what arrows land on.\n          data-tag-target={interactive ? undefined : \"\"}\n          aria-label={removeLabel ?? `Remove ${text ?? \"tag\"}`}\n          aria-keyshortcuts=\"Backspace Delete\"\n          onClick={(e) => {\n            e.stopPropagation();\n            remove();\n          }}\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"relative grid shrink-0 place-items-center rounded-full text-fg-3 outline-none\",\n            sm ? \"size-4\" : \"size-5\",\n            \"transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.08] hover:text-fg active:scale-90 active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n            // Draws at 16–20px; on touch the target grows to 44px.\n            \"before:absolute before:-inset-3 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          <svg aria-hidden viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" className={sm ? \"size-2.5\" : \"size-3\"}>\n            <path d=\"m4 4 8 8M12 4l-8 8\" />\n          </svg>\n        </button>\n      )}\n    </motion.span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * TagGroup\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TagGroupProps = React.ComponentProps<\"div\"> & {\n  /** Names the list for screen readers: \"Labels\", \"Assignees\", \"Filters\". */\n  label: string;\n  /** Shown in place of the tags when there are none. */\n  empty?: React.ReactNode;\n};\n\n/**\n * Lays tags out in wrapping rows. Arrow keys, Home and End move between them; removing one\n * slides the rest over and hands focus to its neighbor. Give each Tag a stable key.\n */\nexport function TagGroup({ label, empty, className, children, onKeyDown, ...rest }: TagGroupProps) {\n  const hasTags = Array.isArray(children) ? children.some(Boolean) : !!children;\n  return (\n    <GroupContext.Provider value>\n      <div\n        data-tag-group=\"\"\n        role=\"list\"\n        aria-label={label}\n        tabIndex={-1}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          const keys = [\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\", \"Home\", \"End\"];\n          if (e.defaultPrevented || !keys.includes(e.key)) return;\n          const list = targets(e.currentTarget);\n          if (!list.length) return;\n          const at = list.indexOf(document.activeElement as HTMLElement);\n          const back = e.key === \"ArrowLeft\" || e.key === \"ArrowUp\";\n          const next = e.key === \"Home\" ? 0 : e.key === \"End\" ? list.length - 1 : at < 0 ? 0 : Math.min(list.length - 1, Math.max(0, at + (back ? -1 : 1)));\n          e.preventDefault();\n          list[next]?.focus();\n        }}\n        className={cn(\"relative flex flex-wrap items-center gap-1.5 outline-none\", className)}\n        {...rest}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {children}\n        </AnimatePresence>\n        {!hasTags && empty}\n      </div>\n    </GroupContext.Provider>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/tag"
}
