{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "truncate",
  "type": "registry:ui",
  "title": "Truncate",
  "description": "Cuts text at the end or middle and unfolds it in place when cut.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/truncate.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { createContext, use, useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n/* -------------------------------------------------------------------------------------------------\n * Where to cut\n * -----------------------------------------------------------------------------------------------*/\n\nconst segmenter = typeof Intl !== \"undefined\" && \"Segmenter\" in Intl ? new Intl.Segmenter() : null;\n// Cut between graphemes, never inside an emoji or an accented letter.\nconst graphemes = (s: string) => (segmenter ? Array.from(segmenter.segment(s), (g) => g.segment) : Array.from(s));\n\n/**\n * How many characters stay whole after the ellipsis in middle truncation, when you don't say.\n * Paths keep their last segment (the file), file names keep their extension plus a few\n * characters (where \"v2\" and \"final\" live), anything else keeps its last few characters\n * (enough to tell two IDs or hashes apart).\n */\nexport function autoTail(text: string) {\n  const n = graphemes(text).length;\n  const slash = Math.max(text.lastIndexOf(\"/\"), text.lastIndexOf(\"\\\\\"));\n  if (slash > 0 && slash < text.length - 1) return Math.min(n, graphemes(text.slice(slash)).length);\n  const ext = /\\.[a-z0-9]{1,8}$/i.exec(text);\n  // Short names leave at least four characters in front, so there's always a head to cut.\n  if (ext && ext.index > 0) return Math.max(0, Math.min(ext[0].length + 6, n - 4));\n  return Math.min(8, Math.floor(n / 3));\n}\n\n/** Splits text into the part that shrinks and the part that stays. Pure, so it renders the same on the server. */\nexport function splitTail(text: string, tail: number | \"auto\" = \"auto\") {\n  const g = graphemes(text);\n  const keep = Math.max(0, Math.min(g.length, tail === \"auto\" ? autoTail(text) : tail));\n  return { head: g.slice(0, g.length - keep).join(\"\"), tail: g.slice(g.length - keep).join(\"\") };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Measuring\n * -----------------------------------------------------------------------------------------------*/\n\nconst overflows = (el: HTMLElement) => el.scrollWidth - el.clientWidth > 0 || el.scrollHeight - el.clientHeight > 1;\n\n/** Watches an element's size, and web fonts arriving, and calls `measure` for each. */\nfunction useMeasure(ref: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // Observing fires once straight away, which is also the first measurement.\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(el);\n    let live = true;\n    document.fonts?.ready.then(() => live && measure());\n    return () => {\n      live = false;\n      ro.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- re-measure when the caller's content changes\n  }, [ref, measure, ...deps]);\n}\n\n/**\n * Whether the text inside `ref` is actually cut off right now. Re-measures on resize, when the\n * text changes and once web fonts have loaded, so it never reports on a fallback font's width.\n */\nexport function useIsTruncated<T extends HTMLElement>(deps: unknown[] = []) {\n  const ref = useRef<T>(null);\n  const [truncated, setTruncated] = useState(false);\n\n  const measure = useCallback(() => {\n    if (ref.current) setTruncated(overflows(ref.current));\n  }, []);\n\n  useMeasure(ref, measure, deps);\n  return { ref, truncated };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Group\n * -----------------------------------------------------------------------------------------------*/\n\nconst GroupContext = createContext<{ delay: number } | null>(null);\n\nexport type TruncateGroupProps = {\n  /** Milliseconds of hover before the first reveal in the group. */\n  delay?: number;\n  /** Once one reveal closes, neighbors hovered within this many milliseconds open instantly. */\n  timeout?: number;\n  children?: React.ReactNode;\n};\n\n/**\n * Wrap a list or table. The first row waits, then moving down the list reveals each\n * truncated row on contact, the way a file browser does.\n */\nexport function TruncateGroup({ delay = 400, timeout = 400, children }: TruncateGroupProps) {\n  return (\n    <GroupContext value={{ delay }}>\n      <Tooltip.Provider delay={delay} timeout={timeout}>\n        {children}\n      </Tooltip.Provider>\n    </GroupContext>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Truncate\n * -----------------------------------------------------------------------------------------------*/\n\n// The nearest thing a keyboard user can land on, so tabbing onto a row reveals its name.\nconst HOST = \"a[href],button,input,select,textarea,summary,[tabindex],[role=row],[role=option],[role=menuitem],[role=treeitem],[role=gridcell]\";\n\n// Surface padding and border, in px. The reveal is offset by exactly this so its text lands on the original.\nconst PAD_X = 6;\nconst PAD_Y = 2;\nconst BORDER = 1;\n\ntype FontMatch = Pick<\n  React.CSSProperties,\n  \"fontFamily\" | \"fontSize\" | \"fontWeight\" | \"letterSpacing\" | \"lineHeight\" | \"color\" | \"fontFeatureSettings\" | \"fontVariantNumeric\"\n>;\n\nfunction readFont(el: HTMLElement): FontMatch {\n  const s = getComputedStyle(el);\n  return {\n    fontFamily: s.fontFamily,\n    fontSize: s.fontSize,\n    fontWeight: s.fontWeight,\n    letterSpacing: s.letterSpacing,\n    lineHeight: s.lineHeight,\n    color: s.color,\n    fontFeatureSettings: s.fontFeatureSettings,\n    fontVariantNumeric: s.fontVariantNumeric,\n  };\n}\n\nexport type TruncateProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The full text. Always what screen readers read and what copying gives you. */\n  children: string;\n  /** Cut at the end, or in the middle so the end (an extension, a file, the last digits of an ID) survives. */\n  position?: \"end\" | \"middle\";\n  /**\n   * Middle only: characters kept whole after the ellipsis. \"auto\" keeps a path's last\n   * segment, a file's extension plus a few characters, or an ID's last few characters.\n   */\n  tail?: number | \"auto\";\n  /** End only: clamp to this many lines instead of one. */\n  lines?: number;\n  /**\n   * How the full text appears when it's cut off. \"inline\" unfolds it in place, over the\n   * original, in the same font; \"tooltip\" shows it above; \"none\" never shows it.\n   */\n  reveal?: \"inline\" | \"tooltip\" | \"none\";\n  /** Milliseconds of hover before revealing. Inside a TruncateGroup, the group's delay. */\n  delay?: number;\n  /** Where to portal the reveal. Defaults to document.body. */\n  container?: Tooltip.Portal.Props[\"container\"];\n  /** Called when the text starts or stops being cut off. */\n  onTruncatedChange?: (truncated: boolean) => void;\n};\n\n/**\n * One line (or a few) of text that cuts itself off cleanly, at the end or in the middle,\n * and offers the full text only when something was actually cut.\n */\nexport function Truncate({\n  children: text,\n  position = \"end\",\n  tail = \"auto\",\n  lines = 1,\n  reveal = \"inline\",\n  delay,\n  container,\n  onTruncatedChange,\n  className,\n  ...rest\n}: TruncateProps) {\n  const group = use(GroupContext);\n  const ref = useRef<HTMLSpanElement>(null);\n  const headRef = useRef<HTMLSpanElement>(null);\n  const tailRef = useRef<HTMLSpanElement>(null);\n  const dotsRef = useRef<HTMLSpanElement>(null);\n  // `cut` is the head's width in px when it's been cut, null when everything fits.\n  const [fit, setFit] = useState<{ truncated: boolean; cut: number | null }>({ truncated: false, cut: null });\n  const [open, setOpen] = useState(false);\n  const [font, setFont] = useState<FontMatch>();\n  const middle = position === \"middle\";\n  const multiline = !middle && lines > 1;\n  const { head, tail: end } = middle ? splitTail(text, tail) : { head: text, tail: \"\" };\n  const truncated = fit.truncated;\n\n  const measure = useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    const commit = (next: { truncated: boolean; cut: number | null }) =>\n      setFit((prev) => (prev.truncated === next.truncated && prev.cut === next.cut ? prev : next));\n    if (!middle) return commit({ truncated: overflows(el), cut: null });\n\n    // Middle: find the longest run of the head that fits beside the ellipsis and the tail,\n    // measured on the rendered glyphs (a Range, no DOM writes), and clip the head to exactly\n    // that width. CSS ellipsis would stop at a glyph and leave a gap before the tail.\n    const node = headRef.current?.firstChild;\n    const tailWidth = tailRef.current?.getBoundingClientRect().width ?? 0;\n    const available = el.clientWidth;\n    if (!(node instanceof Text)) return commit({ truncated: tailWidth - available > 0.5, cut: null });\n    const range = document.createRange();\n    const widthTo = (i: number) => {\n      range.setStart(node, 0);\n      range.setEnd(node, i);\n      return range.getBoundingClientRect().width;\n    };\n    if (widthTo(node.length) + tailWidth <= available + 0.5) return commit({ truncated: false, cut: null });\n\n    const room = available - tailWidth - (dotsRef.current?.getBoundingClientRect().width ?? 0);\n    const stops = [0];\n    for (const g of graphemes(node.data)) stops.push(stops[stops.length - 1] + g.length);\n    let lo = 0;\n    let hi = stops.length - 1;\n    while (lo < hi) {\n      const mid = Math.ceil((lo + hi) / 2);\n      if (widthTo(stops[mid]) <= room) lo = mid;\n      else hi = mid - 1;\n    }\n    // Never leave a space hanging before the ellipsis.\n    let at = stops[lo];\n    while (at > 0 && /\\s/.test(node.data[at - 1])) at--;\n    commit({ truncated: true, cut: at > 0 ? widthTo(at) : 0 });\n  }, [middle]);\n\n  useMeasure(ref, measure, [text, tail, lines]);\n\n  const onChange = useRef(onTruncatedChange);\n  useEffect(() => {\n    onChange.current = onTruncatedChange;\n  });\n  useEffect(() => {\n    onChange.current?.(truncated);\n  }, [truncated]);\n\n\n  const show = useCallback(() => {\n    if (ref.current) setFont(readFont(ref.current));\n    setOpen(true);\n  }, [ref]);\n\n  // Keyboard: reveal when focus lands on the row, link or option this text lives in.\n  useEffect(() => {\n    const host = ref.current?.closest<HTMLElement>(HOST);\n    if (!host || reveal === \"none\") return;\n    const onIn = (e: FocusEvent) => {\n      if (e.target !== host || !host.matches(\":focus-visible\")) return;\n      // A row can hold several (a name and its path): the first one that's cut is the one to unfold.\n      if (host.querySelector(\"[data-slot=truncate][data-truncated]\") === ref.current) show();\n    };\n    const onOut = (e: FocusEvent) => {\n      if (e.target === host) setOpen(false);\n    };\n    host.addEventListener(\"focusin\", onIn);\n    host.addEventListener(\"focusout\", onOut);\n    return () => {\n      host.removeEventListener(\"focusin\", onIn);\n      host.removeEventListener(\"focusout\", onOut);\n    };\n  }, [ref, reveal, show]);\n\n  const inline = reveal === \"inline\";\n\n  return (\n    <Tooltip.Root\n      open={open && truncated}\n      onOpenChange={(next) => (next ? show() : setOpen(false))}\n      disabled={!truncated || reveal === \"none\"}\n      disableHoverablePopup\n    >\n      <Tooltip.Trigger\n        delay={delay ?? group?.delay ?? 400}\n        closeOnClick={false}\n        render={\n          <span\n            ref={ref}\n            data-slot=\"truncate\"\n            data-position={position}\n            data-truncated={truncated || undefined}\n            className={cn(\n              \"min-w-0 max-w-full align-top\",\n              // Middle fills the width it's given, so the measurement reads the space, not its own content.\n              middle ? \"block w-full truncate\" : multiline ? \"inline-block [overflow-wrap:anywhere]\" : \"inline-block truncate\",\n              className,\n            )}\n            style={multiline ? { display: \"-webkit-box\", WebkitBoxOrient: \"vertical\", WebkitLineClamp: lines, overflow: \"hidden\" } : undefined}\n            {...rest}\n          />\n        }\n      >\n        {middle ? (\n          <>\n            {/* The head and tail hold the whole text between them, inline so selecting and copying gives the real name. */}\n            <span aria-hidden>\n              {head && (\n                <span ref={headRef} className={cn(fit.cut !== null && \"inline-block overflow-hidden align-top\")} style={fit.cut !== null ? { width: fit.cut } : undefined}>\n                  {head}\n                </span>\n              )}\n              <span ref={dotsRef} className={cn(\"select-none\", fit.cut === null && \"invisible absolute\")}>\n                …\n              </span>\n              {end && (\n                <span ref={tailRef}>\n                  {end}\n                </span>\n              )}\n            </span>\n            <span className=\"sr-only select-none\">{text}</span>\n          </>\n        ) : (\n          text\n        )}\n      </Tooltip.Trigger>\n\n      <Tooltip.Portal container={container}>\n        <Tooltip.Positioner\n          side={inline ? \"bottom\" : \"top\"}\n          align={inline ? \"start\" : \"center\"}\n          // Inline sits exactly over the original: up by the text's height plus the surface's padding and border.\n          sideOffset={inline ? ({ anchor }) => -anchor.height - PAD_Y - BORDER : 8}\n          alignOffset={inline ? -PAD_X - BORDER : 0}\n          collisionAvoidance={inline ? { side: \"none\", align: \"shift\", fallbackAxisSide: \"none\" } : undefined}\n          collisionPadding={8}\n          className=\"z-(--z-tooltip)\"\n        >\n          <Tooltip.Popup\n            aria-hidden\n            style={inline ? font : undefined}\n            className={cn(\n              \"rounded-md border border-line-2 bg-raised shadow-pop outline-none [overflow-wrap:anywhere]\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n              inline\n                ? cn(\n                    \"pointer-events-none px-[6px] py-[2px]\",\n                    multiline\n                      ? \"w-[calc(var(--anchor-width)+14px)] max-w-(--available-width)\"\n                      : \"w-max max-w-[min(32rem,var(--available-width))]\",\n                    // Unfolds from the original's own box: the clip starts where the text was cut and opens to full size.\n                    \"transition-[opacity,clip-path] [transition-duration:120ms,240ms] ease-out-expo\",\n                    \"data-starting-style:opacity-0\",\n                    multiline\n                      ? \"data-starting-style:[clip-path:inset(0_0_calc(100%-var(--anchor-height)-3px)_0_round_6px)]\"\n                      : \"data-starting-style:[clip-path:inset(0_calc(100%-var(--anchor-width)-7px)_0_0_round_6px)]\",\n                    \"[clip-path:inset(0_0_0_0_round_6px)]\",\n                  )\n                : cn(\n                    \"max-w-[min(20rem,var(--available-width))] px-2 py-1 text-[12px] leading-4 text-fg\",\n                    \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n                    \"data-starting-style:scale-96 data-starting-style:translate-y-0.5 data-starting-style:opacity-0\",\n                    \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n                  ),\n            )}\n          >\n            {text}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/truncate"
}
