{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "selection-toolbar",
  "type": "registry:ui",
  "title": "Selection Toolbar",
  "description": "A Medium-style toolbar that floats above a text selection inside one container, keeps the selection intact and is reachable with Alt+F10.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/selection-toolbar.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface SelectionToolbarContext {\n  /** The selected text (trimmed). */\n  text: string;\n  /** A copy of the selected range; still valid after the toolbar takes focus. */\n  range: Range;\n  /** The scoped container element. */\n  container: HTMLElement;\n  /** Closes the toolbar (and returns focus to the content if the toolbar had it). */\n  close: () => void;\n}\n\nexport interface SelectionToolbarAction {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  /** Keep the toolbar open after the action runs (e.g. bold, then italic). */\n  keepOpen?: boolean;\n  /** Draw a divider before this button. */\n  separator?: boolean;\n  /** Runs the action. Return (or resolve) a short string to flash it as feedback, e.g. \"Copied\". */\n  onSelect?: (context: SelectionToolbarContext) => void | string | Promise<void | string>;\n}\n\nexport interface SelectionToolbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  /** The content whose selections show the toolbar. Selections elsewhere are ignored. */\n  children: React.ReactNode;\n  actions?: SelectionToolbarAction[];\n  /** Called for every action, after its own onSelect. */\n  onAction?: (id: string, context: SelectionToolbarContext) => void;\n  /** Preferred side of the selection; flips when there is no room. */\n  placement?: \"top\" | \"bottom\";\n  /** Gap between selection and toolbar in px. */\n  offset?: number;\n  /** Minimum selected characters before the toolbar appears. */\n  minLength?: number;\n  /** Show text labels next to the icons. */\n  showLabels?: boolean;\n  variant?: \"contrast\" | \"surface\";\n  /** Accessible name of the toolbar. */\n  label?: string;\n  accent?: string;\n}\n\nconst EDGE = 8;\n\nconst icon = (path: React.ReactNode) => (\n  <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\">\n    {path}\n  </svg>\n);\nconst stroke = { stroke: \"currentColor\", strokeWidth: 1.5, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const };\n\n/** Bold, italic, link, highlight, comment and copy. Only copy does something by itself (clipboard). */\nexport const DEFAULT_SELECTION_ACTIONS: SelectionToolbarAction[] = [\n  { id: \"bold\", label: \"Bold\", keepOpen: true, icon: icon(<path d=\"M5 3h4a2.5 2.5 0 0 1 0 5H5zM5 8h4.6a2.5 2.5 0 0 1 0 5H5z\" {...stroke} strokeWidth={1.7} />) },\n  { id: \"italic\", label: \"Italic\", keepOpen: true, icon: icon(<path d=\"M10 3H6.5M9.5 13H6M8.8 3 7.2 13\" {...stroke} />) },\n  { id: \"link\", label: \"Link\", icon: icon(<path d=\"M6.8 9.2a2.6 2.6 0 0 0 3.7 0l2-2a2.6 2.6 0 0 0-3.7-3.7l-.8.8M9.2 6.8a2.6 2.6 0 0 0-3.7 0l-2 2a2.6 2.6 0 0 0 3.7 3.7l.8-.8\" {...stroke} />) },\n  { id: \"highlight\", label: \"Highlight\", keepOpen: true, icon: icon(<path d=\"m9.5 3 3.5 3.5L7.5 12H4v-3.5zM3 14h10\" {...stroke} />) },\n  { id: \"comment\", label: \"Comment\", separator: true, icon: icon(<path d=\"M3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v5a1.5 1.5 0 0 1-1.5 1.5H7l-3 2.5V11h.5\" {...stroke} />) },\n  {\n    id: \"copy\",\n    label: \"Copy\",\n    keepOpen: true,\n    icon: icon(<path d=\"M6 6h6.5v7H6zM3.5 10V3H10\" {...stroke} />),\n    onSelect: async ({ text }) => {\n      await navigator.clipboard?.writeText(text);\n      return \"Copied\";\n    },\n  },\n];\n\n/**\n * Wraps every text node inside `range` in its own element (so selections across paragraphs stay valid\n * HTML), selects the result and returns the new elements. Handy inside action callbacks.\n */\nexport function wrapRange(range: Range, tagName: string, attributes: Record<string, string> = {}): HTMLElement[] {\n  const root = range.commonAncestorContainer;\n  const scope = root.nodeType === Node.TEXT_NODE ? root.parentNode : root;\n  if (!scope) return [];\n  const nodes: Text[] = [];\n  const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);\n  for (let n = walker.nextNode(); n; n = walker.nextNode()) if (range.intersectsNode(n)) nodes.push(n as Text);\n  const { startContainer, startOffset, endContainer, endOffset } = range;\n  const wrapped: HTMLElement[] = [];\n  for (const node of nodes) {\n    const start = node === startContainer ? startOffset : 0;\n    const end = node === endContainer ? endOffset : node.length;\n    if (start >= end || !node.data.slice(start, end).trim()) continue;\n    if (end < node.length) node.splitText(end);\n    const target = start > 0 ? node.splitText(start) : node;\n    const el = document.createElement(tagName);\n    for (const [k, v] of Object.entries(attributes)) el.setAttribute(k, v);\n    target.parentNode?.insertBefore(el, target);\n    el.appendChild(target);\n    wrapped.push(el);\n  }\n  if (wrapped.length) {\n    const next = document.createRange();\n    next.setStartBefore(wrapped[0].firstChild ?? wrapped[0]);\n    next.setEndAfter(wrapped[wrapped.length - 1].lastChild ?? wrapped[wrapped.length - 1]);\n    const selection = document.getSelection();\n    selection?.removeAllRanges();\n    selection?.addRange(next);\n  }\n  return wrapped;\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst VARIANTS = {\n  contrast: \"bg-zinc-900 text-zinc-100 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.5)] dark:bg-zinc-100 dark:text-zinc-900\",\n  surface: \"border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_14px_36px_-14px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#1f2328] dark:text-zinc-100\",\n} as const;\nconst BUTTON_HOVER = {\n  contrast: \"hover:bg-white/10 dark:hover:bg-zinc-900/10\",\n  surface: \"hover:bg-zinc-900/[0.06] dark:hover:bg-white/[0.08]\",\n} as const;\n\n/**\n * A Medium-style floating toolbar for text selections inside one container. It listens to\n * selectionchange only while the container is in use and ignores selections elsewhere. It floats\n * above the selection in the top layer and flips / clamps to stay on screen. Buttons never steal the\n * selection (mousedown is prevented). Alt+F10 moves focus into the toolbar (roving focus with the arrow\n * keys) and Escape closes it and puts focus and the selection back. Callbacks get the text and the range.\n */\nexport function SelectionToolbar({\n  children,\n  actions = DEFAULT_SELECTION_ACTIONS,\n  onAction,\n  placement = \"top\",\n  offset = 10,\n  minLength = 1,\n  showLabels = false,\n  variant = \"contrast\",\n  label = \"Text formatting\",\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  onPointerDown,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ...props\n}: SelectionToolbarProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [open, setOpen] = React.useState(false);\n  const [focusIndex, setFocusIndex] = React.useState(0);\n  const [feedback, setFeedback] = React.useState<{ id: string; text: string } | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const toolbarRef = React.useRef<HTMLDivElement>(null);\n  const saved = React.useRef<{ range: Range; text: string } | null>(null);\n  const returnFocus = React.useRef<HTMLElement | null>(null);\n  // The selection the user dismissed with Escape: it doesn't reopen the toolbar until it changes.\n  const dismissed = React.useRef<Range | null>(null);\n  const listening = React.useRef(false);\n  const pressing = React.useRef(false);\n  const timers = React.useRef({ settle: 0, feedback: 0 });\n  const openRef = React.useRef(open);\n  const settings = React.useRef({ minLength, placement, offset });\n\n  React.useEffect(() => {\n    openRef.current = open;\n    settings.current = { minLength, placement, offset };\n  });\n\n  /** Places the toolbar over (or under) the selection, flipped and clamped to the viewport. */\n  const position = React.useCallback(() => {\n    const bar = toolbarRef.current;\n    const range = saved.current?.range;\n    if (!bar || !range) return;\n    let r = range.getBoundingClientRect();\n    if (!r.width && !r.height) r = range.getClientRects()[0] ?? r;\n    const { placement: side, offset: gap } = settings.current;\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = bar.offsetWidth;\n    const h = bar.offsetHeight;\n    const above = r.top - gap - h;\n    const below = r.bottom + gap;\n    let finalSide = side;\n    if (side === \"top\" && above < EDGE && below + h <= vh - EDGE) finalSide = \"bottom\";\n    if (side === \"bottom\" && below + h > vh - EDGE && above >= EDGE) finalSide = \"top\";\n    const y = Math.max(EDGE, Math.min(finalSide === \"top\" ? above : below, vh - h - EDGE));\n    const centre = r.left + r.width / 2;\n    const x = Math.max(EDGE, Math.min(centre - w / 2, vw - w - EDGE));\n    bar.style.left = `${Math.round(x)}px`;\n    bar.style.top = `${Math.round(y)}px`;\n    bar.style.setProperty(\"--stb-arrow\", `${Math.round(Math.max(12, Math.min(w - 12, centre - x)))}px`);\n    bar.dataset.side = finalSide;\n  }, []);\n\n  const hide = React.useCallback(() => {\n    window.clearTimeout(timers.current.settle);\n    if (openRef.current) setOpen(false);\n  }, []);\n\n  /** Reads the document selection and shows, moves or hides the toolbar. */\n  const check = React.useCallback(() => {\n    const container = containerRef.current;\n    const bar = toolbarRef.current;\n    if (!container) return;\n    // While the toolbar has focus, keep the saved selection even if the live one changes.\n    if (bar?.contains(document.activeElement)) return;\n    const selection = document.getSelection();\n    const range = selection && selection.rangeCount ? selection.getRangeAt(0) : null;\n    const inside = !!range && !range.collapsed && container.contains(range.commonAncestorContainer);\n    const text = inside ? (selection?.toString() ?? \"\").trim() : \"\";\n    if (!range || !inside || text.length < settings.current.minLength) {\n      dismissed.current = null;\n      hide();\n      return;\n    }\n    const d = dismissed.current;\n    if (d) {\n      try {\n        if (d.compareBoundaryPoints(Range.START_TO_START, range) === 0 && d.compareBoundaryPoints(Range.END_TO_END, range) === 0) return;\n      } catch {\n        // Different documents / detached: treat as changed.\n      }\n      dismissed.current = null;\n    }\n    saved.current = { range: range.cloneRange(), text };\n    if (!openRef.current) setOpen(true);\n    else position();\n  }, [hide, position]);\n\n  // selectionchange is a document event: subscribe only while this container is being used.\n  const onSelectionChange = React.useCallback(() => {\n    if (pressing.current) {\n      // Mid-drag: hide if the selection collapsed, otherwise wait for pointerup.\n      const s = document.getSelection();\n      if (!s || s.isCollapsed) hide();\n      return;\n    }\n    window.clearTimeout(timers.current.settle);\n    timers.current.settle = window.setTimeout(check, 120);\n  }, [check, hide]);\n\n  const stopListening = React.useCallback(() => {\n    if (!listening.current) return;\n    listening.current = false;\n    document.removeEventListener(\"selectionchange\", onSelectionChange);\n  }, [onSelectionChange]);\n\n  const startListening = () => {\n    if (listening.current) return;\n    listening.current = true;\n    document.addEventListener(\"selectionchange\", onSelectionChange);\n  };\n\n  React.useEffect(() => {\n    const t = timers.current;\n    return () => {\n      stopListening();\n      window.clearTimeout(t.settle);\n      window.clearTimeout(t.feedback);\n    };\n  }, [stopListening]);\n\n  // Show / hide the top-layer toolbar with a small rise-in.\n  React.useLayoutEffect(() => {\n    const bar = toolbarRef.current;\n    if (!bar) return;\n    if (open) {\n      if (!bar.matches(\":popover-open\")) {\n        try {\n          bar.showPopover();\n        } catch {\n          // No Popover API: position: fixed still works.\n        }\n      }\n      bar.style.visibility = \"visible\";\n      position();\n      const dy = bar.dataset.side === \"bottom\" ? -4 : 4;\n      bar.animate(\n        reduceMotion ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: `translateY(${dy}px) scale(0.97)` }, { opacity: 1, transform: \"none\" }],\n        { duration: reduceMotion ? 120 : 160, easing: \"cubic-bezier(0.2, 0.9, 0.1, 1)\" },\n      );\n      return;\n    }\n    if (bar.style.visibility !== \"visible\") return;\n    const a = bar.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 90 });\n    a.onfinish = () => {\n      if (openRef.current) return;\n      bar.style.visibility = \"hidden\";\n      try {\n        bar.hidePopover();\n      } catch {\n        // not shown as a popover\n      }\n    };\n  }, [open, position, reduceMotion]);\n\n  /** Closes; with `restore`, puts focus and the saved selection back where they were. */\n  const closeAndRestore = React.useCallback(\n    (restore: boolean) => {\n      hide();\n      if (!restore) return;\n      const target = returnFocus.current?.isConnected ? returnFocus.current : null;\n      target?.focus({ preventScroll: true });\n      const range = saved.current?.range;\n      if (range && containerRef.current?.contains(range.commonAncestorContainer)) {\n        const selection = document.getSelection();\n        selection?.removeAllRanges();\n        selection?.addRange(range);\n      }\n    },\n    [hide],\n  );\n\n  // While open: follow scroll / resize, and let Escape close only this layer.\n  React.useEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const onMove = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(position);\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.isComposing) return;\n      event.preventDefault();\n      event.stopPropagation();\n      dismissed.current = saved.current?.range.cloneRange() ?? null;\n      closeAndRestore(toolbarRef.current?.contains(document.activeElement) ?? false);\n    };\n    window.addEventListener(\"scroll\", onMove, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onMove);\n    window.addEventListener(\"keydown\", onKey, true);\n    return () => {\n      cancelAnimationFrame(frame);\n      window.removeEventListener(\"scroll\", onMove, { capture: true });\n      window.removeEventListener(\"resize\", onMove);\n      window.removeEventListener(\"keydown\", onKey, true);\n    };\n  }, [open, position, closeAndRestore]);\n\n  const focusToolbar = () => {\n    const bar = toolbarRef.current;\n    if (!bar) return;\n    const active = document.activeElement;\n    returnFocus.current = active instanceof HTMLElement && active !== document.body ? active : null;\n    const buttons = bar.querySelectorAll<HTMLButtonElement>(\"button\");\n    (buttons[focusIndex] ?? buttons[0])?.focus({ preventScroll: true });\n  };\n\n  const run = async (action: SelectionToolbarAction, index: number) => {\n    const container = containerRef.current;\n    const current = saved.current;\n    if (!container || !current) return;\n    setFocusIndex(index);\n    const viaKeyboard = toolbarRef.current?.contains(document.activeElement) ?? false;\n    const context: SelectionToolbarContext = { text: current.text, range: current.range.cloneRange(), container, close: () => closeAndRestore(viaKeyboard) };\n    let result: void | string = undefined;\n    try {\n      result = await action.onSelect?.(context);\n    } catch {\n      result = undefined;\n    }\n    onAction?.(action.id, context);\n    if (typeof result === \"string\" && result) {\n      setFeedback({ id: action.id, text: result });\n      setAnnouncement(result);\n      window.clearTimeout(timers.current.feedback);\n      timers.current.feedback = window.setTimeout(() => setFeedback(null), 1400);\n    }\n    if (!action.keepOpen) {\n      closeAndRestore(viaKeyboard);\n      return;\n    }\n    // The action may have changed the DOM or the selection: pick up the new one.\n    const selection = document.getSelection();\n    if (selection && selection.rangeCount && !selection.isCollapsed && container.contains(selection.getRangeAt(0).commonAncestorContainer)) {\n      saved.current = { range: selection.getRangeAt(0).cloneRange(), text: selection.toString().trim() || current.text };\n    }\n    // Selecting inside an editor can pull focus back into it; keyboard users stay in the toolbar.\n    if (viaKeyboard && !toolbarRef.current?.contains(document.activeElement)) {\n      toolbarRef.current?.querySelectorAll<HTMLButtonElement>(\"button\")[index]?.focus({ preventScroll: true });\n    }\n    requestAnimationFrame(position);\n  };\n\n  const onToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const count = actions.length;\n    let next = focusIndex;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = (focusIndex + 1) % count;\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = (focusIndex - 1 + count) % count;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = count - 1;\n    else return;\n    event.preventDefault();\n    setFocusIndex(next);\n    toolbarRef.current?.querySelectorAll<HTMLButtonElement>(\"button\")[next]?.focus();\n  };\n\n  const announceOpen = open ? \"Text toolbar available. Press Alt+F10 to reach it.\" : \"\";\n\n  return (\n    <div\n      ref={containerRef}\n      className={className}\n      style={{ [\"--stb-accent\" as string]: accent, ...style }}\n      {...props}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (event.button !== 0) return;\n        startListening();\n        pressing.current = true;\n        const up = () => {\n          pressing.current = false;\n          document.removeEventListener(\"pointerup\", up, true);\n          document.removeEventListener(\"pointercancel\", up, true);\n          // Let the browser finish the selection (double / triple click) before reading it.\n          window.clearTimeout(timers.current.settle);\n          timers.current.settle = window.setTimeout(check, 10);\n        };\n        document.addEventListener(\"pointerup\", up, true);\n        document.addEventListener(\"pointercancel\", up, true);\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        startListening();\n        if (event.key === \"F10\" && event.altKey) {\n          event.preventDefault();\n          if (!openRef.current) check();\n          // The toolbar mounts its position in a layout effect; focus it on the next frame.\n          requestAnimationFrame(() => {\n            if (openRef.current) focusToolbar();\n          });\n        }\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        startListening();\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        const next = event.relatedTarget as Node | null;\n        if (next && (containerRef.current?.contains(next) || toolbarRef.current?.contains(next))) return;\n        // Focus left for good: stop listening; the toolbar stays only if a mouse selection is still there.\n        if (!openRef.current) stopListening();\n      }}\n    >\n      {children}\n\n      <div\n        ref={toolbarRef}\n        role=\"toolbar\"\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        popover=\"manual\"\n        // Never steal the selection: buttons don't take focus from a mouse press.\n        onMouseDown={(event) => event.preventDefault()}\n        onKeyDown={onToolbarKeyDown}\n        onBlur={(event) => {\n          const next = event.relatedTarget as Node | null;\n          if (next && (toolbarRef.current?.contains(next) || containerRef.current?.contains(next))) return;\n          hide();\n          stopListening();\n        }}\n        data-side={placement}\n        className={`group/stb invisible fixed inset-auto m-0 flex w-max items-center gap-0.5 overflow-visible rounded-xl p-1 ${VARIANTS[variant] ?? VARIANTS.contrast}`}\n        style={{ [\"--stb-accent\" as string]: accent }}\n      >\n        {actions.map((action, i) => {\n          const flashing = feedback?.id === action.id;\n          return (\n            <React.Fragment key={action.id}>\n              {action.separator ? <span aria-hidden=\"true\" className={`mx-0.5 h-5 w-px ${variant === \"surface\" ? \"bg-zinc-900/10 dark:bg-white/10\" : \"bg-white/15 dark:bg-zinc-900/15\"}`} /> : null}\n              <button\n                type=\"button\"\n                tabIndex={i === focusIndex ? 0 : -1}\n                aria-label={showLabels ? undefined : action.label}\n                title={showLabels ? undefined : action.label}\n                onFocus={() => setFocusIndex(i)}\n                onClick={() => void run(action, i)}\n                className={`relative inline-flex h-8 items-center justify-center gap-1.5 rounded-lg text-[12.5px] font-medium tracking-[-0.005em] outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--stb-accent)] ${showLabels ? \"px-2.5\" : \"w-8\"} ${BUTTON_HOVER[variant] ?? BUTTON_HOVER.contrast} ${flashing ? \"text-[var(--stb-accent)]\" : \"\"}`}\n              >\n                {flashing ? (\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\">\n                    <path d=\"m3.5 8.5 3 3 6-7\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                  </svg>\n                ) : (\n                  action.icon\n                )}\n                {showLabels ? <span>{flashing ? feedback.text : action.label}</span> : null}\n              </button>\n            </React.Fragment>\n          );\n        })}\n        <span\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute left-[var(--stb-arrow)] size-2.5 -translate-x-1/2 rotate-45 bg-inherit group-data-[side=bottom]/stb:-top-[5px] group-data-[side=top]/stb:-bottom-[5px] ${\n            variant === \"surface\" ? \"border-zinc-900/10 group-data-[side=bottom]/stb:border-l group-data-[side=bottom]/stb:border-t group-data-[side=top]/stb:border-b group-data-[side=top]/stb:border-r dark:border-white/10\" : \"\"\n          }`}\n        />\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {feedback ? announcement : announceOpen}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/selection-toolbar"
}
