{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"expandable-control","type":"registry:component","title":"Expandable Control","description":"Click-to-expand button and chip controls that reveal a label or trailing action through spring layout continuity.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/expandable-control.tsx","type":"registry:component","target":"@components/motion/expandable-control.tsx","content":"\"use client\";\n// beui.dev/components/motion/expandable-control\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type MouseEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// Matches the Motion Patterns \"Layout continuity\" recipe so the surface\n// keeps its identity while its footprint changes.\nconst CONTINUITY_SPRING: Transition = {\n  type: \"spring\",\n  stiffness: 220,\n  damping: 17,\n  mass: 0.85,\n};\n\nconst CONTINUITY_LABEL: Variants = {\n  hidden: { opacity: 0, filter: \"blur(4px)\" },\n  visible: { opacity: 1, filter: \"blur(0px)\" },\n  exit: { opacity: 0, filter: \"blur(4px)\" },\n};\n\ntype ExpandableStateProps = {\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n};\n\nexport interface ExpandableButtonProps\n  extends Omit<HTMLMotionProps<\"button\">, \"children\">,\n    ExpandableStateProps {\n  icon: ReactNode;\n  label: ReactNode;\n}\n\nexport interface ExpandableChipProps extends ExpandableStateProps {\n  label: ReactNode;\n  actionIcon: ReactNode;\n  actionLabel: string;\n  onAction?: () => void;\n  collapseOnAction?: boolean;\n  disabled?: boolean;\n  className?: string;\n  labelClassName?: string;\n  actionClassName?: string;\n}\n\nfunction useExpandableState({\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n}: ExpandableStateProps) {\n  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n  const isControlled = expanded !== undefined;\n  const currentExpanded = expanded ?? internalExpanded;\n\n  const setExpanded = useCallback(\n    (nextExpanded: boolean) => {\n      if (!isControlled) {\n        setInternalExpanded(nextExpanded);\n      }\n      onExpandedChange?.(nextExpanded);\n    },\n    [isControlled, onExpandedChange],\n  );\n\n  return [currentExpanded, setExpanded] as const;\n}\n\nexport function ExpandableButton({\n  icon,\n  label,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  className,\n  onClick,\n  disabled,\n  type = \"button\",\n  \"aria-label\": ariaLabel,\n  ...props\n}: ExpandableButtonProps) {\n  const reduce = useReducedMotion();\n  const [isExpanded, setExpanded] = useExpandableState({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n\n  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {\n    onClick?.(event);\n    if (!event.defaultPrevented) {\n      setExpanded(!isExpanded);\n    }\n  };\n\n  const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;\n\n  return (\n    <motion.span layout transition={transition} className=\"inline-flex\">\n      <motion.button\n        {...props}\n        layout\n        type={type}\n        aria-label={ariaLabel ?? (typeof label === \"string\" ? label : undefined)}\n        aria-expanded={isExpanded}\n        disabled={disabled}\n        className={cn(\n          \"relative inline-flex h-11 min-w-11 items-center overflow-hidden rounded-full border border-border bg-transparent p-1 pr-1 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n        style={{ borderRadius: 9999 }}\n        onClick={handleClick}\n        whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n        transition={transition}\n      >\n        <motion.span\n          layout=\"position\"\n          className=\"grid size-9 shrink-0 place-items-center\"\n          aria-hidden=\"true\"\n          transition={transition}\n        >\n          {icon}\n        </motion.span>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {isExpanded ? (\n            <motion.span\n              key=\"label\"\n              layout\n              variants={CONTINUITY_LABEL}\n              initial={reduce ? { opacity: 0 } : \"hidden\"}\n              animate={reduce ? { opacity: 1 } : \"visible\"}\n              exit={reduce ? { opacity: 0 } : \"exit\"}\n              transition={transition}\n              className=\"relative inline-flex w-max items-center whitespace-nowrap pr-3\"\n              aria-hidden=\"true\"\n            >\n              {label}\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n    </motion.span>\n  );\n}\n\nexport function ExpandableChip({\n  label,\n  actionIcon,\n  actionLabel,\n  onAction,\n  collapseOnAction = true,\n  expanded,\n  defaultExpanded,\n  onExpandedChange,\n  disabled,\n  className,\n  labelClassName,\n  actionClassName,\n}: ExpandableChipProps) {\n  const reduce = useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [isExpanded, setExpanded] = useExpandableState({\n    expanded,\n    defaultExpanded,\n    onExpandedChange,\n  });\n\n  const handleAction = () => {\n    onAction?.();\n    if (collapseOnAction) {\n      setExpanded(false);\n      triggerRef.current?.focus();\n    }\n  };\n\n  const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING;\n\n  return (\n    <motion.span layout transition={transition} className=\"inline-flex\">\n      <motion.div\n        layout\n        className={cn(\n          \"inline-flex h-10 items-center overflow-hidden rounded-full border border-border bg-transparent text-sm text-foreground has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-inset has-[:focus-visible]:ring-ring\",\n          className,\n        )}\n        style={{ borderRadius: 9999 }}\n        transition={transition}\n      >\n        <motion.button\n          layout=\"position\"\n          ref={triggerRef}\n          type=\"button\"\n          aria-expanded={isExpanded}\n          disabled={disabled}\n          className={cn(\n            \"h-full whitespace-nowrap py-0 pl-3 font-medium outline-none disabled:pointer-events-none disabled:opacity-50\",\n            isExpanded ? \"pr-1\" : \"pr-3\",\n            labelClassName,\n          )}\n          onClick={() => setExpanded(!isExpanded)}\n          transition={transition}\n        >\n          {label}\n        </motion.button>\n        <motion.span\n          variants={CONTINUITY_LABEL}\n          className={cn(\n            \"relative inline-flex h-10 shrink-0 items-center overflow-hidden\",\n            isExpanded ? \"w-8\" : \"w-0\",\n          )}\n          aria-hidden={!isExpanded}\n          initial={false}\n          animate={\n            reduce\n              ? { opacity: isExpanded ? 1 : 0 }\n              : isExpanded\n                ? \"visible\"\n                : \"hidden\"\n          }\n          transition={transition}\n        >\n          <motion.button\n            type=\"button\"\n            aria-label={actionLabel}\n            disabled={disabled || !isExpanded}\n            className={cn(\n              \"flex h-10 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground disabled:pointer-events-none\",\n              disabled && \"opacity-50\",\n              actionClassName,\n            )}\n            onClick={handleAction}\n            whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n            transition={transition}\n          >\n            <span aria-hidden=\"true\">{actionIcon}</span>\n          </motion.button>\n        </motion.span>\n      </motion.div>\n    </motion.span>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}