{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "selection-toolbar",
  "type": "registry:ui",
  "title": "Selection Toolbar",
  "description": "Rises over selected text, glides as it changes, and morphs into a link field.",
  "categories": [
    "menus"
  ],
  "dependencies": [
    "@base-ui/react",
    "@floating-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/tooltip.json"
  ],
  "files": [
    {
      "path": "components/ui/selection-toolbar.tsx",
      "content": "\"use client\";\nimport { autoUpdate, computePosition, flip, hide, inline, offset, shift, type VirtualElement } from \"@floating-ui/react\";\nimport { Toolbar } from \"@base-ui/react/toolbar\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { TooltipGroup, TooltipGroupTrigger } from \"@/components/ui/tooltip\";\n\n/* -------------------------------------------------------------------------------------------------\n * Tracking the selection\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TextSelection = { range: Range; text: string; /** How the selection was made. */ pointerType: \"mouse\" | \"touch\" | \"pen\" | \"keyboard\" };\n\nconst noop = () => () => {};\n\nfunction selectionIn(el: HTMLElement | null, pointerType: TextSelection[\"pointerType\"]): TextSelection | null {\n  const sel = typeof window === \"undefined\" ? null : window.getSelection();\n  if (!el || !sel || sel.rangeCount === 0 || sel.isCollapsed) return null;\n  if (!el.contains(sel.anchorNode) || !el.contains(sel.focusNode)) return null;\n  const text = sel.toString();\n  if (!text.trim()) return null;\n  return { range: sel.getRangeAt(0).cloneRange(), text, pointerType };\n}\n\n/**\n * The live, non-empty text selection inside `target`, or null. It waits for the pointer to be\n * released (so it doesn't chase a drag) and lets keyboard selections settle for 180ms.\n * While `frozen` is true it holds the last selection, e.g. while focus is in the toolbar.\n */\nexport function useTextSelection(\n  target: React.RefObject<HTMLElement | null>,\n  {\n    frozen = false,\n    settle = 180,\n    holdWithin,\n  }: {\n    frozen?: boolean;\n    settle?: number;\n    /** While focus is inside this element (the toolbar), the selection is held rather than dropped. */\n    holdWithin?: React.RefObject<HTMLElement | null>;\n  } = {},\n) {\n  const [selection, setSelection] = useState<TextSelection | null>(null);\n  const frozenRef = useRef(frozen);\n  useEffect(() => {\n    frozenRef.current = frozen;\n  }, [frozen]);\n\n  useEffect(() => {\n    const el = target.current;\n    if (!el) return;\n    let dragging = false;\n    let timer = 0;\n    let via: TextSelection[\"pointerType\"] = \"keyboard\";\n    const held = () => frozenRef.current || !!holdWithin?.current?.contains(document.activeElement);\n    const read = () => {\n      window.clearTimeout(timer);\n      if (!held()) setSelection(selectionIn(el, via));\n    };\n    const onDown = (e: PointerEvent) => {\n      if (e.button !== 0) return;\n      dragging = true;\n      via = e.pointerType === \"touch\" || e.pointerType === \"pen\" ? e.pointerType : \"mouse\";\n      // A new press inside the text starts over: the old toolbar leaves at once.\n      if (!held()) setSelection(null);\n    };\n    const onUp = () => {\n      if (!dragging) return;\n      dragging = false;\n      // The selection finalizes after pointerup; read it on the next frame.\n      requestAnimationFrame(read);\n    };\n    const onChange = () => {\n      if (dragging || held()) return;\n      window.clearTimeout(timer);\n      const now = selectionIn(el, via);\n      // Collapses hide immediately; growing or new selections settle first.\n      if (!now) setSelection(null);\n      else timer = window.setTimeout(read, settle);\n    };\n    const onKey = () => {\n      via = \"keyboard\";\n    };\n    el.addEventListener(\"keydown\", onKey);\n    el.addEventListener(\"pointerdown\", onDown);\n    document.addEventListener(\"pointerup\", onUp);\n    document.addEventListener(\"pointercancel\", onUp);\n    document.addEventListener(\"selectionchange\", onChange);\n    return () => {\n      window.clearTimeout(timer);\n      el.removeEventListener(\"keydown\", onKey);\n      el.removeEventListener(\"pointerdown\", onDown);\n      document.removeEventListener(\"pointerup\", onUp);\n      document.removeEventListener(\"pointercancel\", onUp);\n      document.removeEventListener(\"selectionchange\", onChange);\n    };\n  }, [target, settle, holdWithin]);\n\n  const clear = useCallback(() => setSelection(null), []);\n  return { selection, clear };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Context for the parts\n * -----------------------------------------------------------------------------------------------*/\n\ntype Ctx = {\n  /** The selected text when the toolbar opened. */\n  text: string;\n  /** Puts the saved selection back in the document, e.g. before applying a format from an input. */\n  restoreSelection: () => void;\n  /** Hides the toolbar until the next selection and returns focus to the text. */\n  dismiss: () => void;\n};\nconst SelectionToolbarContext = createContext<Ctx | null>(null);\n\n/** Inside the toolbar: the selected text, a way to restore the selection, and a way to close. */\nexport function useSelectionToolbar() {\n  const ctx = useContext(SelectionToolbarContext);\n  if (!ctx) throw new Error(\"useSelectionToolbar must be used inside a SelectionToolbar\");\n  return ctx;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The toolbar\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SelectionToolbarProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The element whose text selections summon the toolbar: a contentEditable, an article, a comment. */\n  target: React.RefObject<HTMLElement | null>;\n  /** Accessible name of the toolbar. */\n  label?: string;\n  /** Called when the toolbar appears or leaves, with the selected text. */\n  onSelectionChange?: (text: string | null) => void;\n  /** Where to portal the toolbar. Defaults to document.body. */\n  container?: HTMLElement | null;\n  children: React.ReactNode;\n};\n\n/**\n * A floating toolbar that follows the text selection inside `target`. It rises above the\n * first selected line, glides when the selection changes, and leaves when it collapses.\n * Alt+F10 in the text moves focus into it; Escape sends focus back with the selection intact.\n */\nexport function SelectionToolbar({ target, label = \"Text formatting\", onSelectionChange, container, className, children, ...rest }: SelectionToolbarProps) {\n  const [frozen, setFrozen] = useState(false);\n  const floatingRef = useRef<HTMLDivElement>(null);\n  const { selection, clear } = useTextSelection(target, { frozen, holdWithin: floatingRef });\n  const reduce = useReducedMotion();\n  const open = selection !== null;\n\n  const innerRef = useRef<HTMLDivElement>(null);\n  const rangeRef = useRef<Range | null>(null);\n  const shown = useRef(false);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const [side, setSide] = useState<\"top\" | \"bottom\">(\"top\");\n  const [anchorHidden, setAnchorHidden] = useState(false);\n\n  useEffect(() => {\n    onSelectionChange?.(selection?.text ?? null);\n  }, [selection, onSelectionChange]);\n\n  // Position with floating-ui, but write the result into motion values so a new selection glides\n  // on a spring while scroll and resize updates land on the same frame.\n  const glideNext = useRef(false);\n  useLayoutEffect(() => {\n    // Kept for restoring, and for the rects below; a collapse keeps the last range for the exit.\n    if (selection) rangeRef.current = selection.range;\n    const floating = floatingRef.current;\n    if (!open || !floating) return;\n    const touch = selection?.pointerType === \"touch\";\n    // The selection's rects, read live so scrolling and reflow keep it attached.\n    const reference: VirtualElement = {\n      getBoundingClientRect: () => rangeRef.current?.getBoundingClientRect() ?? new DOMRect(),\n      getClientRects: () => rangeRef.current?.getClientRects() ?? ([] as unknown as DOMRectList),\n      contextElement: target.current ?? undefined,\n    };\n    const update = () => {\n      computePosition(reference, floating, {\n        // On touch the system's own selection menu sits above the text, so the toolbar goes below.\n        placement: touch ? \"bottom\" : \"top\",\n        strategy: \"fixed\",\n        middleware: [inline(), offset(touch ? 14 : 8), flip({ padding: 8 }), shift({ padding: 8 }), hide()],\n      }).then((pos) => {\n        const glide = glideNext.current && shown.current && !reduce;\n        glideNext.current = false;\n        if (glide) {\n          animate(x, pos.x, spring.snappy);\n          animate(y, pos.y, spring.snappy);\n        } else {\n          x.set(pos.x);\n          y.set(pos.y);\n        }\n        shown.current = true;\n        setSide(pos.placement.startsWith(\"bottom\") ? \"bottom\" : \"top\");\n        setAnchorHidden(!!pos.middlewareData.hide?.referenceHidden);\n      });\n    };\n    glideNext.current = true;\n    update();\n    return autoUpdate(reference, floating, update, { animationFrame: false });\n  }, [open, selection, target, x, y, reduce]);\n\n  useEffect(() => {\n    if (!open) shown.current = false;\n  }, [open]);\n\n  // The surface's width follows its content on a spring, so swapping buttons for an input morphs.\n  useEffect(() => {\n    const inner = innerRef.current;\n    if (!open || !inner) return;\n    let first = true;\n    const ro = new ResizeObserver(() => {\n      const w = inner.offsetWidth;\n      if (first || reduce) width.set(w);\n      else animate(width, w, spring.snappy);\n      first = false;\n    });\n    ro.observe(inner);\n    return () => ro.disconnect();\n  }, [open, reduce, width]);\n\n  const restoreSelection = () => {\n    const range = rangeRef.current;\n    const sel = window.getSelection();\n    if (!range || !sel) return;\n    target.current?.focus({ preventScroll: true });\n    sel.removeAllRanges();\n    sel.addRange(range);\n  };\n\n  const dismiss = () => {\n    setFrozen(false);\n    restoreSelection();\n    clear();\n  };\n\n  // Alt+F10 (the editor convention) jumps into the toolbar; Escape in the text hides it.\n  useEffect(() => {\n    const el = target.current;\n    if (!el || !open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.altKey && e.key === \"F10\") {\n        e.preventDefault();\n        floatingRef.current?.querySelector<HTMLElement>(\"button:not([disabled]),input\")?.focus();\n      } else if (e.key === \"Escape\") {\n        clear();\n      }\n    };\n    el.addEventListener(\"keydown\", onKey);\n    return () => el.removeEventListener(\"keydown\", onKey);\n  }, [target, open, clear]);\n\n  const ctx: Ctx = { text: selection?.text ?? \"\", restoreSelection, dismiss };\n\n  // Portals only exist on the client; the server renders nothing here, and so does hydration.\n  const mounted = useSyncExternalStore(noop, () => true, () => false);\n  if (!mounted) return null;\n\n  return createPortal(\n    <AnimatePresence onExitComplete={() => width.set(\"auto\")}>\n      {open && (\n        <motion.div\n          ref={floatingRef}\n          style={{ x, y }}\n          className=\"fixed left-0 top-0 z-(--z-popover)\"\n          data-side={side}\n          // Focus inside the toolbar freezes the selection it belongs to.\n          onFocus={() => setFrozen(true)}\n          onBlur={(e) => {\n            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFrozen(false);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.stopPropagation();\n              setFrozen(false);\n              restoreSelection();\n            }\n          }}\n          // Pressing the toolbar must not collapse the selection it acts on.\n          onMouseDown={(e) => {\n            if (!(e.target as HTMLElement).closest(\"input,textarea\")) e.preventDefault();\n          }}\n        >\n          <motion.div\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: side === \"top\" ? 4 : -4, filter: \"blur(2px)\" }}\n            animate={{ opacity: anchorHidden ? 0 : 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.97, filter: \"blur(1px)\", transition: { duration: 0.11, ease: ease.in } }}\n            transition={{ duration: reduce ? 0.12 : 0.18, ease: ease.out }}\n            style={{ width, transformOrigin: side === \"top\" ? \"50% 100%\" : \"50% 0%\" }}\n            className={cn(\n              \"overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop\",\n              anchorHidden && \"pointer-events-none\",\n              className,\n            )}\n            {...(rest as React.ComponentProps<typeof motion.div>)}\n          >\n            <SelectionToolbarContext.Provider value={ctx}>\n              <TooltipGroup side={side} sideOffset={10} delay={400}>\n                <Toolbar.Root ref={innerRef} aria-label={label} className=\"flex h-9 w-max items-center gap-0.5 p-1\">\n                  {children}\n                </Toolbar.Root>\n              </TooltipGroup>\n            </SelectionToolbarContext.Provider>\n          </motion.div>\n        </motion.div>\n      )}\n    </AnimatePresence>,\n    container ?? document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SelectionToolbarButtonProps = Omit<Toolbar.Button.Props, \"className\" | \"children\"> & {\n  /** Accessible name, and the tooltip. */\n  label: string;\n  shortcut?: string[];\n  /** A toggle's current state, e.g. whether the selection is bold. */\n  pressed?: boolean;\n  /** Show the label as text beside the icon instead of only in the tooltip. */\n  showLabel?: boolean;\n  icon?: React.ReactNode;\n  className?: string;\n};\n\nexport function SelectionToolbarButton({ label, shortcut, pressed, showLabel = false, icon, className, ...rest }: SelectionToolbarButtonProps) {\n  const button = (\n    <Toolbar.Button\n      aria-label={showLabel ? undefined : label}\n      aria-pressed={pressed}\n      className={cn(\n        \"relative inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg text-[12.5px] font-medium text-fg-2 outline-none\",\n        showLabel ? \"px-2\" : \"w-7\",\n        \"transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.06] hover:text-fg active:scale-[0.92] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        \"aria-pressed:bg-fg/[0.11] aria-pressed:text-fg aria-pressed:hover:bg-fg/[0.14]\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-40\",\n        \"[&_svg]:shrink-0\",\n        className,\n      )}\n      {...rest}\n    >\n      {icon}\n      {showLabel && label}\n    </Toolbar.Button>\n  );\n  if (showLabel && !shortcut) return button;\n  return (\n    <TooltipGroupTrigger content={label} shortcut={shortcut}>\n      {button}\n    </TooltipGroupTrigger>\n  );\n}\n\nexport function SelectionToolbarSeparator({ className, ...rest }: Omit<Toolbar.Separator.Props, \"className\"> & { className?: string }) {\n  return <Toolbar.Separator className={cn(\"mx-1 h-4 w-px shrink-0 bg-line-2\", className)} {...rest} />;\n}\n\nexport type SelectionToolbarFieldProps = Omit<React.ComponentProps<\"input\">, \"onSubmit\"> & {\n  /** Called with the value on Enter, after the saved selection has been put back. */\n  onSubmit: (value: string) => void;\n  /** Called on Escape, after the saved selection has been put back. */\n  onCancel: () => void;\n  /** Label for the submit button. */\n  submitLabel?: string;\n};\n\n/** An input that swaps in for the buttons, e.g. a link URL. Enter applies, Escape backs out. */\nexport function SelectionToolbarField({ onSubmit, onCancel, submitLabel = \"Apply\", defaultValue, className, ...rest }: SelectionToolbarFieldProps) {\n  const { restoreSelection } = useSelectionToolbar();\n  const [value, setValue] = useState(typeof defaultValue === \"string\" ? defaultValue : \"\");\n  const submit = () => {\n    restoreSelection();\n    onSubmit(value.trim());\n  };\n  return (\n    <div className=\"flex items-center gap-1 pl-2\">\n      <Toolbar.Input\n        autoFocus\n        value={value}\n        onChange={(e) => setValue(e.currentTarget.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            submit();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            restoreSelection();\n            onCancel();\n          }\n        }}\n        className={cn(\n          \"h-7 w-52 min-w-0 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[12.5px]\",\n          className,\n        )}\n        {...rest}\n      />\n      <Toolbar.Button\n        disabled={!value.trim()}\n        focusableWhenDisabled={false}\n        onClick={submit}\n        className={cn(\n          \"inline-flex h-7 shrink-0 items-center rounded-lg bg-fg px-2.5 text-[12px] font-medium text-frame outline-none\",\n          \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.96] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"data-disabled:opacity-40\",\n        )}\n      >\n        {submitLabel}\n      </Toolbar.Button>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/selection-toolbar"
}
