{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "show-more",
  "type": "registry:ui",
  "title": "Show More",
  "description": "Clamps long text with a fading last line, offering more only when it overflows.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/show-more.tsx",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Measure = { full: number; clamp: number };\n\n/** The nearest ancestor that scrolls vertically, or null for the page itself. */\nfunction scrollParent(el: HTMLElement): HTMLElement | null {\n  for (let p = el.parentElement; p; p = p.parentElement) {\n    const y = getComputedStyle(p).overflowY;\n    if ((y === \"auto\" || y === \"scroll\") && p.scrollHeight > p.clientHeight) return p;\n  }\n  return null;\n}\n\nexport type ShowMoreProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  children: React.ReactNode;\n  /** Lines visible while collapsed. */\n  lines?: number;\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  moreLabel?: React.ReactNode;\n  lessLabel?: React.ReactNode;\n  /** Classes for the text box (type size and color live here). */\n  contentClassName?: string;\n};\n\n/**\n * Clamps text to a number of lines and fades the last one out. The button exists\n * only when the text actually overflows, which is measured, not guessed from length.\n */\nexport function ShowMore({\n  children,\n  lines = 3,\n  expanded,\n  defaultExpanded = false,\n  onExpandedChange,\n  moreLabel = \"Show more\",\n  lessLabel = \"Show less\",\n  className,\n  contentClassName,\n  style,\n  ...rest\n}: ShowMoreProps) {\n  const [open, setOpen] = useControllableState({ value: expanded, defaultValue: defaultExpanded, onChange: onExpandedChange });\n  const outer = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n  const root = useRef<HTMLDivElement>(null);\n  // null until measured: the server can't know whether the text overflows.\n  const [m, setM] = useState<Measure | null>(null);\n  // Height animates only for a toggle; a resize or a font swap snaps to the new size.\n  const [moving, setMoving] = useState(false);\n  const reduce = useReducedMotion();\n  const id = useId();\n\n  useEffect(() => {\n    const o = outer.current;\n    const i = inner.current;\n    if (!o || !i) return;\n    const ro = new ResizeObserver(() => {\n      const cs = getComputedStyle(o);\n      const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5;\n      const next = { full: i.offsetHeight, clamp: Math.round(lh * lines) };\n      setM((prev) => (prev && prev.full === next.full && prev.clamp === next.clamp ? prev : next));\n    });\n    ro.observe(i);\n    return () => ro.disconnect();\n  }, [lines]);\n\n  const overflowing = m ? m.full > m.clamp + 1 : null;\n  const clamped = overflowing !== false;\n  const distance = m ? Math.abs(m.full - m.clamp) : 0;\n  // Longer text travels further, so it gets a little longer, within 220–420ms.\n  const duration = reduce ? 0 : Math.round(Math.min(420, Math.max(220, 180 + distance * 0.5)));\n\n  const toggle = (next: boolean) => {\n    if (next === open) return;\n    if (!next && root.current) {\n      // Collapsing from far down the text can strand the reader below the fold; bring the top back.\n      const el = root.current;\n      const parent = scrollParent(el);\n      const top = parent ? parent.getBoundingClientRect().top : 0;\n      if (el.getBoundingClientRect().top < top) el.scrollIntoView({ block: \"start\", behavior: reduce ? \"auto\" : \"smooth\" });\n    }\n    if (!reduce) setMoving(true);\n    setOpen(next);\n  };\n\n  return (\n    <div ref={root} data-state={open ? \"open\" : \"closed\"} className={cn(\"flex min-w-0 scroll-mt-4 flex-col items-start\", className)} style={style} {...rest}>\n      <div\n        ref={outer}\n        id={id}\n        onTransitionEnd={(e) => {\n          if (e.target === e.currentTarget && e.propertyName === \"height\") setMoving(false);\n        }}\n        // A link hidden in the clamped lines can still take focus: open up so it is seen.\n        onFocusCapture={(e) => {\n          if (open || !overflowing || !m || !inner.current) return;\n          const y = (e.target as HTMLElement).getBoundingClientRect().bottom - inner.current.getBoundingClientRect().top;\n          if (y > m.clamp) toggle(true);\n        }}\n        style={\n          {\n            \"--lines\": lines,\n            height: m && overflowing ? (open ? m.full : m.clamp) : undefined,\n            transitionDuration: moving ? `${open ? duration : Math.round(duration * 0.8)}ms` : \"0ms\",\n          } as React.CSSProperties\n        }\n        className={cn(\n          \"relative w-full overflow-clip text-[13px] leading-[1.6] text-fg-2 [overflow-anchor:none]\",\n          open ? \"ease-out-quart\" : \"ease-in-out-quart\",\n          \"transition-[height,mask-position]\",\n          // Before hydration, CSS clamps by line height so the server render already looks right.\n          !m && !open && \"max-h-[calc(var(--lines)*1lh)]\",\n          // The mask is taller than the box by the fade; sliding it down past the edge clears the fade as the text opens.\n          clamped &&\n            \"[mask-image:linear-gradient(to_bottom,var(--fg)_calc(100%-1.2lh),transparent)] [mask-repeat:no-repeat] [mask-size:100%_calc(100%+1.2lh)]\",\n          clamped && (open ? \"[mask-position:0_0]\" : \"[mask-position:0_100%]\"),\n          contentClassName,\n        )}\n      >\n        <div ref={inner} className=\"text-pretty\">\n          {children}\n        </div>\n      </div>\n\n      {overflowing !== false && (\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={id}\n          // Unmeasured, the button holds its space invisibly so nothing jumps when it appears.\n          className={cn(\n            \"group/more relative -mx-2 mt-1 flex h-8 select-none items-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium text-fg-2\",\n            \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n            \"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n            \"pointer-coarse:before:absolute pointer-coarse:before:-inset-y-1.5 pointer-coarse:before:inset-x-0 pointer-coarse:before:content-['']\",\n            overflowing === null && \"invisible opacity-0\",\n          )}\n          onClick={() => toggle(!open)}\n        >\n          <svg\n            width=\"14\"\n            height=\"14\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n            className={cn(\n              \"shrink-0 text-fg-3 transition-[rotate,color] duration-[240ms] ease-in-out-quart motion-reduce:transition-none group-hover/more:text-fg-2\",\n              open && \"rotate-180\",\n            )}\n          >\n            <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n          </svg>\n          {/* Both labels share one cell so the button keeps its width; only the current one is visible to assistive tech. */}\n          <span className=\"grid overflow-hidden py-0.5\">\n            <Swap show={!open}>{moreLabel}</Swap>\n            <Swap show={open} from=\"below\">\n              {lessLabel}\n            </Swap>\n          </span>\n        </button>\n      )}\n    </div>\n  );\n}\n\nfunction Swap({ show, from = \"above\", children }: { show: boolean; from?: \"above\" | \"below\"; children: React.ReactNode }) {\n  return (\n    <span\n      className={cn(\n        \"col-start-1 row-start-1 whitespace-nowrap transition-[opacity,translate,visibility] duration-200 ease-out-expo motion-reduce:translate-y-0\",\n        show ? \"visible translate-y-0 opacity-100\" : cn(\"invisible opacity-0\", from === \"below\" ? \"translate-y-2\" : \"-translate-y-2\"),\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/show-more"
}
