{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"Testimonial quotes woven into a paragraph that expand inline on hover or focus.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Star } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { FocusEvent } from \"react\";\nimport { Fragment, useCallback, useEffect, useRef, useState } from \"react\";\n\nconst TOKEN_PATTERN = /\\{\\{([\\w-]+)\\}\\}/g;\nconst DEFAULT_AVATAR_SIZE = 22;\nconst CARD_AVATAR_MULTIPLIER = 2;\nconst CARD_WIDTH_PX = 288;\nconst EDGE_PADDING_PX = 8;\nconst CARD_GAP_PX = 8;\nconst ENTER_OFFSET_PX = 4;\nconst CLOSE_DELAY_MS = 140;\nconst STAR_COUNT = 5;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" } as const;\n/** Leaving is a decision already made — get out of the way faster. */\nconst EXIT_TRANSITION = { duration: 0.15, ease: EASE_OUT } as const;\nconst INSTANT = { duration: 0 } as const;\n\ntype TextSegment =\n  | { type: \"text\"; value: string }\n  | { type: \"token\"; id: string };\n\ntype CardPosition = {\n  left: number;\n  originX: number;\n  spaceAbove: number;\n  spaceBelow: number;\n  triggerBottom: number;\n  triggerTop: number;\n  width: number;\n};\n\nconst parseSegments = (text: string): TextSegment[] => {\n  const segments: TextSegment[] = [];\n  let lastIndex = 0;\n\n  for (const match of text.matchAll(TOKEN_PATTERN)) {\n    const [full, id] = match;\n    const index = match.index ?? 0;\n    if (index > lastIndex) {\n      segments.push({ type: \"text\", value: text.slice(lastIndex, index) });\n    }\n    segments.push({ id, type: \"token\" });\n    lastIndex = index + full.length;\n  }\n\n  if (lastIndex < text.length) {\n    segments.push({ type: \"text\", value: text.slice(lastIndex) });\n  }\n\n  return segments;\n};\n\nexport type Testimonial = {\n  avatar: string;\n  id: string;\n  name: string;\n  quote: string;\n  rating?: number;\n  role: string;\n};\n\nexport type InlineTestimonialsProps = {\n  avatarSize?: number;\n  className?: string;\n  onOpenChange?: (id: string | null) => void;\n  openId?: string | null;\n  testimonials: Testimonial[];\n  text: string;\n};\n\nconst useHoverDevice = () => {\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mediaQuery.matches);\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  return isHoverDevice;\n};\n\nconst QuoteCard = ({\n  avatarSize,\n  cardId,\n  testimonial,\n}: {\n  avatarSize: number;\n  cardId: string;\n  testimonial: Testimonial;\n}) => (\n  <div\n    className={cn(\n      \"rounded-xl border border-foreground/10 bg-background p-4 text-left\",\n      // Ambient + direct light rather than one flat drop, so the card reads as\n      // lifted off the paragraph instead of stamped onto it.\n      \"shadow-[0_1px_2px_rgb(0_0_0/0.05),0_14px_32px_-14px_rgb(0_0_0/0.28)]\",\n      \"dark:border-foreground/15 dark:bg-smooth-100 dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),0_14px_32px_-14px_rgb(0_0_0/0.7)]\"\n    )}\n    id={cardId}\n  >\n    <div className=\"flex items-center gap-3\">\n      <img\n        alt=\"\"\n        className=\"shrink-0 rounded-full object-cover\"\n        height={avatarSize * CARD_AVATAR_MULTIPLIER}\n        src={testimonial.avatar}\n        width={avatarSize * CARD_AVATAR_MULTIPLIER}\n      />\n      <div className=\"min-w-0\">\n        <p className=\"truncate font-semibold text-foreground text-sm\">\n          {testimonial.name}\n        </p>\n        <p className=\"truncate text-muted-foreground text-xs\">\n          {testimonial.role}\n        </p>\n      </div>\n    </div>\n    <blockquote className=\"mt-3 text-foreground/80 text-sm leading-relaxed\">\n      {testimonial.quote}\n    </blockquote>\n    {testimonial.rating ? (\n      <div\n        aria-label={`Rated ${testimonial.rating} out of 5`}\n        className=\"mt-3 flex items-center gap-0.5\"\n      >\n        {Array.from({ length: STAR_COUNT }, (_, position) => {\n          const filled = position < Math.round(testimonial.rating ?? 0);\n          return (\n            <Star\n              aria-hidden=\"true\"\n              className={cn(\n                \"size-3.5\",\n                // Neutral: the one accent in this component is the open\n                // trigger's underline, and a row of pink stars would outrank it.\n                filled\n                  ? \"fill-foreground/75 text-foreground/75\"\n                  : \"fill-transparent text-foreground/20\"\n              )}\n              key={`${cardId}-star-${position}`}\n            />\n          );\n        })}\n      </div>\n    ) : null}\n  </div>\n);\n\nconst InlineTestimonials = ({\n  text,\n  testimonials,\n  avatarSize = DEFAULT_AVATAR_SIZE,\n  openId,\n  onOpenChange,\n  className,\n}: InlineTestimonialsProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const isHoverDevice = useHoverDevice();\n  const [internalOpenId, setInternalOpenId] = useState<string | null>(null);\n  const [position, setPosition] = useState<CardPosition | null>(null);\n  const [cardHeight, setCardHeight] = useState(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement | null>(null);\n  const triggerRefs = useRef(new Map<string, HTMLButtonElement>());\n  const closeTimerRef = useRef<number | null>(null);\n\n  const isControlled = openId !== undefined;\n  const activeOpenId = isControlled ? openId : internalOpenId;\n\n  const clearCloseTimer = useCallback(() => {\n    if (closeTimerRef.current !== null) {\n      window.clearTimeout(closeTimerRef.current);\n      closeTimerRef.current = null;\n    }\n  }, []);\n\n  useEffect(() => clearCloseTimer, [clearCloseTimer]);\n\n  const commitOpenId = useCallback(\n    (id: string | null) => {\n      if (!isControlled) {\n        setInternalOpenId(id);\n      }\n      onOpenChange?.(id);\n    },\n    [isControlled, onOpenChange]\n  );\n\n  const open = useCallback(\n    (id: string) => {\n      clearCloseTimer();\n      commitOpenId(id);\n    },\n    [clearCloseTimer, commitOpenId]\n  );\n\n  const close = useCallback(() => {\n    clearCloseTimer();\n    commitOpenId(null);\n  }, [clearCloseTimer, commitOpenId]);\n\n  // A delayed close keeps the card alive while the pointer crosses the gap\n  // between the trigger and the card — the usual source of popover flicker.\n  const scheduleClose = useCallback(() => {\n    clearCloseTimer();\n    closeTimerRef.current = window.setTimeout(() => {\n      commitOpenId(null);\n    }, CLOSE_DELAY_MS);\n  }, [clearCloseTimer, commitOpenId]);\n\n  const measure = useCallback((id: string) => {\n    const container = containerRef.current;\n    const trigger = triggerRefs.current.get(id);\n    if (!(container && trigger)) {\n      return;\n    }\n    const containerRect = container.getBoundingClientRect();\n    const triggerRect = trigger.getBoundingClientRect();\n    const width = Math.min(\n      CARD_WIDTH_PX,\n      Math.max(containerRect.width - EDGE_PADDING_PX * 2, 0)\n    );\n    const center =\n      triggerRect.left - containerRect.left + triggerRect.width / 2;\n    const maxLeft = Math.max(\n      containerRect.width - width - EDGE_PADDING_PX,\n      EDGE_PADDING_PX\n    );\n    const left = Math.min(\n      Math.max(center - width / 2, EDGE_PADDING_PX),\n      maxLeft\n    );\n    setPosition({\n      left,\n      originX: center - left,\n      // Vertical room is measured against the viewport, not the paragraph:\n      // the paragraph is only a few lines tall, so the card would always\n      // \"not fit\" and never get the chance to sit below its trigger.\n      spaceAbove: triggerRect.top,\n      spaceBelow: window.innerHeight - triggerRect.bottom,\n      triggerBottom: triggerRect.bottom - containerRect.top,\n      triggerTop: triggerRect.top - containerRect.top,\n      width,\n    });\n  }, []);\n\n  // Clamping happens against live measurements, so the card can never\n  // overflow the container sideways — and it follows the trigger on\n  // resize and scroll.\n  useEffect(() => {\n    if (!activeOpenId) {\n      return;\n    }\n    measure(activeOpenId);\n    const remeasure = () => measure(activeOpenId);\n    window.addEventListener(\"resize\", remeasure);\n    window.addEventListener(\"scroll\", remeasure, true);\n    return () => {\n      window.removeEventListener(\"resize\", remeasure);\n      window.removeEventListener(\"scroll\", remeasure, true);\n    };\n  }, [activeOpenId, measure]);\n\n  // The rendered popover's own height decides whether it still fits below.\n  // This measures the gap-plus-card wrapper, not the card alone.\n  useEffect(() => {\n    const node = cardRef.current;\n    if (node) {\n      setCardHeight(node.offsetHeight);\n    }\n  }, [activeOpenId, position]);\n\n  useEffect(() => {\n    if (!activeOpenId) {\n      return;\n    }\n    const handleKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\") {\n        return;\n      }\n      const trigger = triggerRefs.current.get(activeOpenId);\n      close();\n      trigger?.focus();\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [activeOpenId, close]);\n\n  const handleContainerBlur = (event: FocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget as Node | null;\n    if (next && containerRef.current?.contains(next)) {\n      return;\n    }\n    close();\n  };\n\n  const handleTriggerFocus = (\n    event: FocusEvent<HTMLButtonElement>,\n    id: string\n  ) => {\n    // Only keyboard focus opens the card; a mouse click is handled by onClick,\n    // otherwise focus-then-click would open and immediately close it again.\n    if (event.currentTarget.matches(\":focus-visible\")) {\n      open(id);\n    }\n  };\n\n  const testimonialsById = new Map(\n    testimonials.map((entry) => [entry.id, entry])\n  );\n  const segments = parseSegments(text);\n  const activeTestimonial = activeOpenId\n    ? testimonialsById.get(activeOpenId)\n    : undefined;\n\n  // Flip above the trigger when the card would spill past the viewport.\n  const requiredSpace = cardHeight + EDGE_PADDING_PX;\n  const flipUp = Boolean(\n    position &&\n      cardHeight > 0 &&\n      position.spaceBelow < requiredSpace &&\n      position.spaceAbove >= requiredSpace\n  );\n  // Enter and exit travel in the same direction, so opening and closing read\n  // as one movement rather than two unrelated ones.\n  const enterOffset = flipUp ? ENTER_OFFSET_PX : -ENTER_OFFSET_PX;\n\n  return (\n    <div\n      className={cn(\"relative text-foreground/90 leading-relaxed\", className)}\n      onBlur={handleContainerBlur}\n      ref={containerRef}\n    >\n      <p>\n        {segments.map((segment, index) => {\n          if (segment.type === \"text\") {\n            return (\n              <Fragment key={`text-${index}-${segment.value.slice(0, 8)}`}>\n                {segment.value}\n              </Fragment>\n            );\n          }\n\n          const testimonial = testimonialsById.get(segment.id);\n          if (!testimonial) {\n            return null;\n          }\n\n          const isOpen = activeOpenId === testimonial.id;\n          const cardId = `inline-testimonial-${testimonial.id}`;\n\n          return (\n            <button\n              aria-controls={cardId}\n              aria-expanded={isOpen}\n              // An enriched word, not a chip: the name keeps the prose's size\n              // and colour, and only picks up a face and a hairline rule under\n              // it. Nothing here breaks the line's rhythm.\n              className={cn(\n                \"group mx-[0.12em] inline-flex cursor-pointer items-center gap-[0.32em] rounded-[0.3em] align-middle font-medium text-foreground outline-none\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n              )}\n              key={segment.id}\n              onClick={() => (isOpen ? close() : open(testimonial.id))}\n              onFocus={(event) => handleTriggerFocus(event, testimonial.id)}\n              onMouseEnter={() => {\n                if (isHoverDevice) {\n                  open(testimonial.id);\n                }\n              }}\n              onMouseLeave={() => {\n                if (isHoverDevice) {\n                  scheduleClose();\n                }\n              }}\n              ref={(node) => {\n                if (node) {\n                  triggerRefs.current.set(testimonial.id, node);\n                } else {\n                  triggerRefs.current.delete(testimonial.id);\n                }\n              }}\n              type=\"button\"\n            >\n              <img\n                alt=\"\"\n                className={cn(\n                  \"shrink-0 rounded-full object-cover ring-1 transition-[box-shadow] duration-150 ease-out\",\n                  isOpen ? \"ring-brand/60\" : \"ring-foreground/15\"\n                )}\n                height={avatarSize}\n                src={testimonial.avatar}\n                width={avatarSize}\n              />\n              <span\n                className={cn(\n                  // leading-none keeps the rule tight under the name: without\n                  // it the span inherits the paragraph's line-height and the\n                  // underline drops to the bottom of the whole line box.\n                  \"border-b-[1.5px] pb-[0.08em] leading-none transition-colors duration-150 ease-out\",\n                  isOpen\n                    ? \"border-brand\"\n                    : \"border-foreground/25 group-hover:border-foreground/50\"\n                )}\n              >\n                {testimonial.name}\n              </span>\n            </button>\n          );\n        })}\n      </p>\n      <AnimatePresence>\n        {activeTestimonial && position ? (\n          // AnimatePresence drives exits through motion children of its own, so\n          // the positioned wrapper IS the animated element. Its padding is the\n          // gap AND a hover bridge: it keeps the pointer inside the card's\n          // hover region on its way over from the trigger.\n          <motion.div\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            className={cn(\"absolute z-20\", flipUp ? \"pb-2\" : \"pt-2\")}\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: INSTANT }\n                : {\n                    opacity: 0,\n                    scale: 0.97,\n                    transition: EXIT_TRANSITION,\n                    y: enterOffset,\n                  }\n            }\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 0, scale: 0.97, y: enterOffset }\n            }\n            key=\"inline-testimonial-popover\"\n            onMouseEnter={clearCloseTimer}\n            onMouseLeave={scheduleClose}\n            ref={cardRef}\n            style={{\n              left: position.left,\n              top: flipUp\n                ? position.triggerTop - cardHeight\n                : position.triggerBottom,\n              // Scale from the trigger's centre, at the card's own edge rather\n              // than the bridge padding's.\n              transformOrigin: `${position.originX}px ${\n                flipUp ? `${cardHeight - CARD_GAP_PX}px` : `${CARD_GAP_PX}px`\n              }`,\n              width: position.width,\n            }}\n            transition={shouldReduceMotion ? INSTANT : SPRING}\n          >\n            <QuoteCard\n              avatarSize={avatarSize}\n              cardId={`inline-testimonial-${activeTestimonial.id}`}\n              testimonial={activeTestimonial}\n            />\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n};\n\nexport default InlineTestimonials;\n","path":"index.tsx","target":"components/smoothui/inline-testimonials/index.tsx","type":"registry:ui"}],"name":"inline-testimonials","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Inline Testimonials","type":"registry:ui"}