{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"show-more","type":"registry:ui","title":"Show More","description":"Height animates, text does not reflow.","dependencies":["motion"],"categories":["content"],"docs":"https://www.interior.dev/docs/show-more","files":[{"path":"registry/interior/show-more.tsx","type":"registry:ui","target":"components/interior/show-more.tsx","content":"\"use client\";\n\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport {\n  motion,\n  useIsomorphicLayoutEffect,\n  useReducedMotion,\n} from \"motion/react\";\n\nconst DISCLOSE = {\n  type: \"spring\",\n  stiffness: 190,\n  damping: 30,\n  mass: 1,\n} as const;\n\nconst SMALL = {\n  type: \"spring\",\n  stiffness: 700,\n  damping: 46,\n  mass: 0.5,\n} as const;\n\nconst INSTANT = { duration: 0 } as const;\n\ntype Metrics = { line: number; full: number };\n\nexport type UseShowMoreOptions = {\n  lines?: number;\n  maxHeight?: number;\n  defaultExpanded?: boolean;\n  expanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n};\n\nexport type UseShowMoreResult = {\n  contentRef: React.RefObject<HTMLDivElement | null>;\n  expanded: boolean;\n  open: boolean;\n  toggle: () => void;\n  setExpanded: (next: boolean) => void;\n  height: number | null;\n  collapsedHeight: number | null;\n  fullHeight: number | null;\n  expandable: boolean;\n  capped: boolean;\n  scrollable: boolean;\n};\n\nexport function useShowMore({\n  lines = 3,\n  maxHeight = 320,\n  defaultExpanded = false,\n  expanded: expandedProp,\n  onExpandedChange,\n}: UseShowMoreOptions = {}): UseShowMoreResult {\n  const [uncontrolled, setUncontrolled] = useState(defaultExpanded);\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n\n  const expanded = expandedProp ?? uncontrolled;\n\n  const notify = useRef(onExpandedChange);\n  notify.current = onExpandedChange;\n\n  const setExpanded = useCallback(\n    (next: boolean) => {\n      if (expandedProp === undefined) setUncontrolled(next);\n      notify.current?.(next);\n    },\n    [expandedProp],\n  );\n\n  const toggle = useCallback(\n    () => setExpanded(!expanded),\n    [setExpanded, expanded],\n  );\n\n  useIsomorphicLayoutEffect(() => {\n    const el = contentRef.current;\n    if (!el) return;\n\n    const read = () => {\n      const styles = getComputedStyle(el);\n      const parsed = Number.parseFloat(styles.lineHeight);\n      const line = Number.isFinite(parsed)\n        ? parsed\n        : Number.parseFloat(styles.fontSize) * 1.5;\n      const full = el.scrollHeight;\n\n      setMetrics((prev) =>\n        prev && prev.line === line && prev.full === full\n          ? prev\n          : { line, full },\n      );\n    };\n\n    read();\n\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const clamped = metrics ? metrics.line * lines : 0;\n  const expandable = metrics ? metrics.full - clamped > 1 : true;\n  const capped = metrics ? metrics.full > maxHeight : false;\n  const collapsedHeight = metrics ? Math.min(clamped, metrics.full) : null;\n  const fullHeight = metrics ? Math.min(metrics.full, maxHeight) : null;\n  const open = expanded && expandable;\n\n  return {\n    contentRef,\n    expanded,\n    open,\n    toggle,\n    setExpanded,\n    height: open ? fullHeight : collapsedHeight,\n    collapsedHeight,\n    fullHeight,\n    expandable,\n    capped,\n    scrollable: open && capped,\n  };\n}\n\nexport type ShowMoreProps = UseShowMoreOptions & {\n  children: React.ReactNode;\n  moreLabel?: string;\n  lessLabel?: string;\n  label?: string;\n  className?: string;\n};\n\nexport function ShowMore({\n  children,\n  moreLabel = \"Show more\",\n  lessLabel = \"Show less\",\n  label = \"Details\",\n  lines = 3,\n  maxHeight = 320,\n  defaultExpanded,\n  expanded,\n  onExpandedChange,\n  className = \"\",\n}: ShowMoreProps) {\n  const reduced = useReducedMotion();\n  const regionId = useId();\n  const regionRef = useRef<HTMLDivElement>(null);\n\n  const { contentRef, open, toggle, height, expandable, capped, scrollable } =\n    useShowMore({\n      lines,\n      maxHeight,\n      defaultExpanded,\n      expanded,\n      onExpandedChange,\n    });\n\n  const press = () => {\n    if (open) regionRef.current?.scrollTo({ top: 0 });\n    toggle();\n  };\n\n  const veiled = expandable && (!open || scrollable);\n\n  return (\n    <div\n      className={`text-[13.5px] leading-relaxed text-stone-700 dark:text-stone-200 ${className}`}\n    >\n      <div className=\"relative\">\n        <motion.div\n          ref={regionRef}\n          id={regionId}\n          role={scrollable ? \"region\" : undefined}\n          aria-label={scrollable ? label : undefined}\n          tabIndex={scrollable ? 0 : undefined}\n          initial={false}\n          animate={height === null ? {} : { height }}\n          transition={reduced ? INSTANT : DISCLOSE}\n          style={{\n            maxHeight: height === null ? `${lines}lh` : undefined,\n            overflowY: scrollable ? \"auto\" : \"hidden\",\n\n            scrollbarGutter: capped ? \"stable\" : undefined,\n          }}\n          className=\"overflow-hidden overscroll-contain rounded-[6px] outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.10] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n        >\n          <div ref={contentRef}>{children}</div>\n        </motion.div>\n        <motion.div\n          aria-hidden\n          initial={false}\n          animate={{ opacity: veiled ? 1 : 0 }}\n          transition={reduced ? INSTANT : SMALL}\n          className=\"pointer-events-none absolute inset-x-0 bottom-0 h-9 bg-gradient-to-t from-white to-white/0 dark:from-stone-900 dark:to-stone-900/0\"\n        />\n      </div>\n      <div className=\"mt-2 flex h-8 items-center\">\n        {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}\n        <button\n          type=\"button\"\n          onClick={press}\n          aria-expanded={open}\n          aria-controls={regionId}\n          className={`inline-flex h-8 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-2.5 text-[12.5px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${\n            expandable ? \"\" : \"pointer-events-none invisible\"\n          }`}\n        >\n          <span className=\"grid text-left\">\n            <motion.span\n              aria-hidden={open}\n              className=\"col-start-1 row-start-1\"\n              initial={false}\n              animate={{ opacity: open ? 0 : 1 }}\n              transition={reduced ? INSTANT : SMALL}\n            >\n              {moreLabel}\n            </motion.span>\n            <motion.span\n              aria-hidden={!open}\n              className=\"col-start-1 row-start-1\"\n              initial={false}\n              animate={{ opacity: open ? 1 : 0 }}\n              transition={reduced ? INSTANT : SMALL}\n            >\n              {lessLabel}\n            </motion.span>\n          </span>\n          <motion.svg\n            aria-hidden\n            width=\"12\"\n            height=\"12\"\n            viewBox=\"0 0 256 256\"\n            fill=\"none\"\n            className=\"text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ rotate: open ? 180 : 0 }}\n            transition={reduced ? INSTANT : SMALL}\n          >\n            <polyline\n              points=\"208 96 128 176 48 96\"\n              stroke=\"currentColor\"\n              strokeWidth=\"16\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </motion.svg>\n        </button>\n      </div>\n    </div>\n  );\n}\n"}]}