{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "read-more",
  "type": "registry:ui",
  "title": "Read More",
  "description": "Line-clamped text that expands to its measured height, with a fade at the clamp and a button only when it overflows.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/read-more.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface ReadMoreProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The text. Paragraphs, links and inline markup are fine. */\n  children: React.ReactNode;\n  /** Number of lines shown while collapsed. */\n  lines?: number;\n  /** Expand / collapse duration in ms (0 = instant). */\n  duration?: number;\n  /** Fade the last visible line into the background while collapsed. */\n  fade?: boolean;\n  /** Clicking anywhere on the collapsed text expands it (links inside still work normally). */\n  expandOnClick?: boolean;\n  /** Button label while collapsed. */\n  moreLabel?: string;\n  /** Button label while expanded. */\n  lessLabel?: string;\n  /** Controlled expanded state. */\n  expanded?: boolean;\n  /** Initial expanded state when uncontrolled. */\n  defaultExpanded?: boolean;\n  /** Called when the reader expands or collapses the text. */\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Focus ring and button hover colour (any CSS colour). */\n  accent?: string;\n  /** Extra classes for the toggle button. */\n  buttonClassName?: string;\n}\n\nconst INTERACTIVE = \"a, button, input, select, textarea, summary, [role='button'], [tabindex]\";\nconst EASE = \"cubic-bezier(0.22, 0.8, 0.24, 1)\";\n\n/** Height the element would have with the clamp applied — measured synchronously, then restored. */\nfunction measureClamped(el: HTMLElement, lines: number) {\n  const saved = el.getAttribute(\"style\");\n  el.style.height = \"\";\n  el.style.display = \"-webkit-box\";\n  el.style.setProperty(\"-webkit-box-orient\", \"vertical\");\n  el.style.setProperty(\"-webkit-line-clamp\", String(lines));\n  el.style.overflow = \"hidden\";\n  const height = el.getBoundingClientRect().height;\n  if (saved === null) el.removeAttribute(\"style\");\n  else el.setAttribute(\"style\", saved);\n  return height;\n}\n\n/**\n * Line-clamped text with a measured-height expand/collapse. The button only appears when the\n * content really overflows its clamp (checked with ResizeObserver, so it reacts to width and font\n * changes). Heights are measured, never guessed: collapsing animates from the full height to the\n * exact clamped height, and the clamp (with its ellipsis) is only applied once the motion ends.\n */\nexport function ReadMore({\n  children,\n  lines = 3,\n  duration = 380,\n  fade = true,\n  expandOnClick = false,\n  moreLabel = \"Read more\",\n  lessLabel = \"Show less\",\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  accent = \"#FF7448\",\n  buttonClassName = \"\",\n  className = \"\",\n  style,\n  ...props\n}: ReadMoreProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const contentId = React.useId();\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const animationRef = React.useRef<Animation | null>(null);\n\n  const [inner, setInner] = React.useState(defaultExpanded);\n  const open = expandedProp ?? inner;\n  const [overflows, setOverflows] = React.useState(false);\n  const [animating, setAnimating] = React.useState(false);\n  const instant = reduceMotion || duration <= 0;\n\n  // Whenever `open` flips (internally or from a controlled prop), start an animation phase.\n  const [prevOpen, setPrevOpen] = React.useState(open);\n  if (prevOpen !== open) {\n    setPrevOpen(open);\n    setAnimating(!instant);\n  }\n\n  const clamped = !open && !animating;\n\n  // Run the height animation for the phase that was just committed.\n  const skipFirst = React.useRef(true);\n  React.useLayoutEffect(() => {\n    if (skipFirst.current) {\n      skipFirst.current = false;\n      return;\n    }\n    const el = contentRef.current;\n    if (!el) return;\n    if (!animating) {\n      animationRef.current?.cancel();\n      animationRef.current = null;\n      return;\n    }\n    // Start from wherever the content is right now: mid-animation if the reader clicked twice,\n    // otherwise the clamped height (expanding) or the full height (collapsing). The clamp is\n    // already gone from the DOM at this point, so the clamped start is measured.\n    const from = animationRef.current || !open ? el.getBoundingClientRect().height : measureClamped(el, lines);\n    animationRef.current?.cancel();\n    const to = open ? el.scrollHeight : measureClamped(el, lines);\n    const animation = el.animate([{ height: `${from}px` }, { height: `${to}px` }], {\n      duration,\n      easing: EASE,\n      fill: \"forwards\",\n    });\n    animationRef.current = animation;\n    animation.onfinish = () => {\n      if (animationRef.current !== animation) return;\n      setAnimating(false);\n      if (!open) {\n        // Keep the toggle on screen when a long passage collapses above the viewport.\n        const button = buttonRef.current;\n        const top = button?.getBoundingClientRect().top ?? 0;\n        if (top < 0) window.scrollBy({ top: top - 24 });\n      }\n    };\n  }, [open, animating, duration, lines]);\n\n  React.useEffect(() => () => animationRef.current?.cancel(), []);\n\n  // Show the button only when the clamp actually hides something.\n  React.useEffect(() => {\n    const el = contentRef.current;\n    if (!el) return;\n    const check = () => {\n      if (animating) return;\n      // Clamped: compare the full scroll height with the visible box. Expanded: measure the clamp.\n      const visible = clamped ? el.clientHeight : measureClamped(el, lines);\n      setOverflows(el.scrollHeight > visible + 1);\n    };\n    const ro = new ResizeObserver(check);\n    ro.observe(el);\n    let disposed = false;\n    document.fonts?.ready.then(() => {\n      if (!disposed) check();\n    });\n    return () => {\n      disposed = true;\n      ro.disconnect();\n    };\n  }, [lines, clamped, animating, children]);\n\n  const setOpen = (next: boolean) => {\n    if (next === open) return;\n    if (!next) {\n      // Focus inside the part that is about to be hidden moves to the toggle.\n      const active = document.activeElement;\n      if (active && contentRef.current?.contains(active)) buttonRef.current?.focus({ preventScroll: true });\n    }\n    if (expandedProp === undefined) setInner(next);\n    onExpandedChange?.(next);\n  };\n\n  const showFade = fade && overflows && !open;\n  const fadeMask = \"linear-gradient(to bottom, #000 calc(100% - var(--rm-fade)), transparent)\";\n  const maskSize = showFade ? \"100% 100%\" : \"100% calc(100% + var(--rm-fade))\";\n  const maskTransition = instant ? undefined : `mask-size ${Math.round(duration * 0.8)}ms ${EASE}, -webkit-mask-size ${Math.round(duration * 0.8)}ms ${EASE}`;\n\n  const contentStyle: React.CSSProperties = {\n    [\"--rm-fade\" as string]: \"1.9em\",\n    ...(clamped\n      ? { display: \"-webkit-box\", WebkitBoxOrient: \"vertical\", WebkitLineClamp: lines, overflow: \"hidden\" }\n      : animating\n        ? { overflow: \"hidden\" }\n        : null),\n    ...(fade && overflows\n      ? {\n          maskImage: fadeMask,\n          WebkitMaskImage: fadeMask,\n          maskRepeat: \"no-repeat\",\n          WebkitMaskRepeat: \"no-repeat\",\n          maskSize,\n          WebkitMaskSize: maskSize,\n          transition: maskTransition,\n        }\n      : null),\n  };\n\n  const clickable = expandOnClick && overflows && !open;\n\n  return (\n    <div {...props} className={`flex flex-col items-start ${className}`} style={{ [\"--rm-accent\" as string]: accent, ...style }}>\n      <div\n        ref={contentRef}\n        id={contentId}\n        data-state={open ? \"expanded\" : \"collapsed\"}\n        className={`w-full ${clickable ? \"cursor-pointer\" : \"\"}`}\n        style={contentStyle}\n        onFocus={(event) => {\n          // Keyboard focus landing on a link hidden by the clamp reveals the rest of the text.\n          if (!open && overflows && event.target !== event.currentTarget) setOpen(true);\n        }}\n        onClick={(event) => {\n          if (!clickable) return;\n          if ((event.target as HTMLElement).closest(INTERACTIVE)) return;\n          const selection = window.getSelection();\n          if (selection && !selection.isCollapsed) return;\n          setOpen(true);\n        }}\n      >\n        {children}\n      </div>\n      {overflows && (\n        <button\n          ref={buttonRef}\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={contentId}\n          onClick={() => setOpen(!open)}\n          className={`group/rm mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-full py-1 font-mono text-[11px] font-medium uppercase tracking-[0.14em] text-zinc-900 outline-hidden transition-colors hover:text-[var(--rm-accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--rm-accent)] dark:text-zinc-100 ${buttonClassName}`}\n        >\n          <span>{open ? lessLabel : moreLabel}</span>\n          <svg\n            aria-hidden=\"true\"\n            viewBox=\"0 0 16 16\"\n            className={`size-3.5 transition-transform duration-300 motion-reduce:transition-none ${open ? \"rotate-180\" : \"\"}`}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M4 6.5 8 10.5l4-4\" />\n          </svg>\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/read-more"
}
