{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"selection-actions","title":"Selection Actions","description":"Highlight a passage and hand it to the agent to rewrite.","dependencies":["iconoir-react@7.12.1"],"registryDependencies":["https://ward.so/r/foundation.json","https://ward.so/r/button.json","https://ward.so/r/shimmer.json","https://ward.so/r/stream-text.json"],"files":[{"path":"registry/ward/SelectionActions.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport {\n  ArrowUp,\n  ChatBubbleQuestion,\n  Check,\n  EmojiSatisfied,\n  NavArrowRight,\n  Refresh,\n  Scissor,\n  Spark,\n  TextBox,\n  Xmark,\n} from \"iconoir-react\";\nimport { Button } from \"@/registry/ward/ui/button\";\nimport { Shimmer } from \"./Shimmer\";\nimport { StreamText } from \"./StreamText\";\n\n/* ─────────────────────────────────────────────────────────\n * SELECTION ACTIONS\n * A contextual AI bar attached beneath selected text.\n * The global theme owns its surface; this component only\n * composes existing surface, ink, accent, radius and motion\n * tokens.\n * ───────────────────────────────────────────────────────── */\n\nconst LEAD = \"Pistachio holds the top slot all weekend. \";\nconst PICKED =\n  \"Plan it first thing Saturday so the batch has time to firm up before the afternoon rush.\";\nconst REWRITE =\n  \"Plan pistachio first thing Saturday so the batch has time to fully firm before the afternoon rush.\";\n\n/* The passage: lead-in text, the selected `original`, and the streamed `rewrite`. */\nexport type SelectionText = {\n  lead: string;\n  original: string;\n  rewrite: string;\n};\n\n/* A single AI action offered in the bar. Omit `action` for a no-op button\n * (e.g. Explain); `busyLabel` is the gerund shown while it runs. */\nexport type SelectionAction = {\n  id: string;\n  icon: ReactNode;\n  action?: string;\n  busyLabel?: string;\n};\n\n/* The action set: `primary` are always visible; `more` reveal on expand. */\nexport type SelectionActionSet = {\n  primary: SelectionAction[];\n  more: SelectionAction[];\n};\n\n/* Prominent copy strings. */\nexport type SelectionActionsLabels = {\n  keep: string;\n  discard: string;\n  placeholder: string;\n};\n\nconst DEFAULT_TEXT: SelectionText = {\n  lead: LEAD,\n  original: PICKED,\n  rewrite: REWRITE,\n};\n\nconst DEFAULT_LABELS: SelectionActionsLabels = {\n  keep: \"Keep\",\n  discard: \"Discard\",\n  placeholder: \"Describe edits\",\n};\n\ntype Mode = \"idle\" | \"thinking\" | \"streaming\" | \"result\";\n\nconst iconProps = {\n  width: 14,\n  height: 14,\n  strokeWidth: 1.8,\n  \"aria-hidden\": true,\n} as const;\n\nconst icons = {\n  explain: <ChatBubbleQuestion {...iconProps} />,\n  improve: <Spark {...iconProps} />,\n  shorten: <Scissor {...iconProps} />,\n  tone: <EmojiSatisfied {...iconProps} />,\n  grammar: <TextBox {...iconProps} />,\n  send: (\n    <ArrowUp\n      width=\"16\"\n      height=\"16\"\n      strokeWidth=\"2.4\"\n      aria-hidden=\"true\"\n    />\n  ),\n  chevron: <NavArrowRight {...iconProps} />,\n  check: <Check {...iconProps} />,\n  close: <Xmark {...iconProps} />,\n  retry: <Refresh {...iconProps} />,\n};\n\n/* the single \"keep\" affirm — solid ink with a hairline (not the atom's filled\n * highlight) shadow, so it stays a local one-off rather than a Button variant */\nconst primary =\n  \"inline-flex h-7 shrink-0 items-center gap-1 rounded-full bg-ink px-2.5 text-[12.5px] font-normal text-canvas shadow-hairline transition-[opacity,transform] duration-150 hover:opacity-90 active:scale-[0.96]\";\n\n\nconst DEFAULT_ACTIONS: SelectionActionSet = {\n  primary: [\n    { id: \"Explain\", icon: icons.explain },\n    { id: \"Improve\", icon: icons.improve, action: \"Improve\", busyLabel: \"Improving\" },\n  ],\n  more: [\n    { id: \"Shorten\", icon: icons.shorten, action: \"Shorten\", busyLabel: \"Shortening\" },\n    { id: \"Tone\", icon: icons.tone, action: \"Change tone\", busyLabel: \"Changing tone\" },\n    { id: \"Grammar\", icon: icons.grammar, action: \"Fix grammar\" },\n  ],\n};\n\nexport type SelectionActionsProps = {\n  /** Accepted for gallery/registry parity; not used by this bar. */\n  variant?: string;\n  /** The passage shown above the bar. */\n  text?: Partial<SelectionText>;\n  /** The AI actions offered in the bar. */\n  actions?: SelectionActionSet;\n  /** Prominent copy strings. */\n  labels?: Partial<SelectionActionsLabels>;\n  /** Called with the action name whenever an edit is run. */\n  onAction?: (action: string) => void;\n};\n\nexport default function SelectionActions({\n  text: textProp,\n  actions = DEFAULT_ACTIONS,\n  labels,\n  onAction,\n}: SelectionActionsProps = {}) {\n  const passage = { ...DEFAULT_TEXT, ...textProp };\n  const copy = { ...DEFAULT_LABELS, ...labels };\n  const [shown, setShown] = useState(false);\n  const [mode, setMode] = useState<Mode>(\"idle\");\n  const [action, setAction] = useState(\"Improve\");\n  const [prompt, setPrompt] = useState(\"\");\n  const [typingWidth, setTypingWidth] = useState<number | null>(null);\n  const [expanded, setExpanded] = useState(false);\n  const [anchor, setAnchor] = useState({ x: 0, y: 0 });\n  const [positioned, setPositioned] = useState(false);\n\n  const hostRef = useRef<HTMLDivElement>(null);\n  const selectionRef = useRef<HTMLSpanElement>(null);\n  const barRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const frameRef = useRef<number | null>(null);\n  const previousModeRef = useRef<Mode>(\"idle\");\n  const lastWidthRef = useRef(0);\n  const widthAnimationRef = useRef<Animation | null>(null);\n\n  useEffect(() => {\n    const timer = window.setTimeout(() => setShown(true), 280);\n    return () => window.clearTimeout(timer);\n  }, []);\n\n  useEffect(() => {\n    if (mode !== \"thinking\") return;\n    const timer = window.setTimeout(() => setMode(\"streaming\"), 700);\n    return () => window.clearTimeout(timer);\n  }, [mode]);\n\n  /* Attach beneath the final selected line, while centering the bar\n   * against the complete selection bounds. requestAnimationFrame batches\n   * streaming reflow measurements and avoids visible intermediate positions. */\n  const place = useCallback(() => {\n    if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n    frameRef.current = requestAnimationFrame(() => {\n      const host = hostRef.current;\n      const selection = selectionRef.current;\n      if (!host || !selection) return;\n\n      const bounds = selection.getBoundingClientRect();\n      const lines = Array.from(selection.getClientRects());\n      const lastLine = lines.at(-1);\n      if (!lastLine) return;\n\n      const hostBounds = host.getBoundingClientRect();\n      const next = {\n        x: Math.round(bounds.left - hostBounds.left + bounds.width / 2),\n        y: Math.round(lastLine.bottom - hostBounds.top + 8),\n      };\n\n      setAnchor((current) =>\n        current.x === next.x && current.y === next.y ? current : next,\n      );\n      setPositioned(true);\n    });\n  }, []);\n\n  useLayoutEffect(() => {\n    place();\n  }, [mode, place]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    const observer = new ResizeObserver(place);\n    observer.observe(host);\n    window.addEventListener(\"resize\", place);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", place);\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n    };\n  }, [place]);\n\n  /* Intrinsic width handles the preset expansion. When the entire content\n   * changes between idle, loading and confirmation, animate from the last\n   * rendered width to the new intrinsic width before the browser paints. */\n  useLayoutEffect(() => {\n    const bar = barRef.current;\n    const content = contentRef.current;\n    if (!bar || !content) return;\n\n    const nextWidth = Math.ceil(content.getBoundingClientRect().width) + 8;\n    const previousWidth =\n      lastWidthRef.current || Math.ceil(bar.getBoundingClientRect().width);\n\n    if (\n      previousModeRef.current !== mode &&\n      Math.abs(nextWidth - previousWidth) > 1\n    ) {\n      widthAnimationRef.current?.cancel();\n      const animation = bar.animate(\n        [\n          { width: `${previousWidth}px` },\n          { width: `${nextWidth}px` },\n        ],\n        {\n          duration: 320,\n          easing: \"cubic-bezier(0.23,1,0.32,1)\",\n        },\n      );\n      widthAnimationRef.current = animation;\n      animation.onfinish = () => {\n        lastWidthRef.current = nextWidth;\n        widthAnimationRef.current = null;\n      };\n    } else {\n      lastWidthRef.current = nextWidth;\n    }\n\n    previousModeRef.current = mode;\n  }, [mode]);\n\n  useEffect(() => {\n    const content = contentRef.current;\n    if (!content) return;\n\n    const observer = new ResizeObserver(() => {\n      if (widthAnimationRef.current?.playState === \"running\") return;\n      lastWidthRef.current =\n        Math.ceil(content.getBoundingClientRect().width) + 8;\n    });\n    observer.observe(content);\n    return () => {\n      observer.disconnect();\n      widthAnimationRef.current?.cancel();\n    };\n  }, []);\n\n  const run = (nextAction: string) => {\n    setAction(nextAction);\n    setExpanded(false);\n    setMode(\"thinking\");\n    onAction?.(nextAction);\n  };\n\n  const reset = () => {\n    setExpanded(false);\n    setPrompt(\"\");\n    setTypingWidth(null);\n    setAction(\"Improve\");\n    setMode(\"idle\");\n  };\n\n  const busy = mode === \"thinking\" || mode === \"streaming\";\n  const visible = shown && positioned;\n  const hasPrompt = prompt.trim().length > 0;\n  const busyLabelMap: Record<string, string> = {};\n  for (const item of [...actions.primary, ...actions.more]) {\n    if (item.action && item.busyLabel) busyLabelMap[item.action] = item.busyLabel;\n  }\n  const busyLabel = busyLabelMap[action] ?? \"Editing\";\n\n  return (\n    <div className=\"w-full max-w-[460px]\">\n      <div ref={hostRef} className=\"relative select-none pb-12\">\n        <p className=\"text-[13px] leading-relaxed text-ink\">\n          {passage.lead}\n          <span\n            ref={selectionRef}\n            className=\"box-decoration-clone rounded-[3px] bg-[color-mix(in_srgb,var(--highlight)_14%,var(--card))] text-ink dark:bg-highlight-tint\"\n          >\n            {mode === \"idle\" || mode === \"thinking\" ? (\n              passage.original\n            ) : mode === \"streaming\" ? (\n              <StreamText\n                text={passage.rewrite}\n                onProgress={place}\n                onDone={() => setMode(\"result\")}\n              />\n            ) : (\n              passage.rewrite\n            )}\n          </span>\n        </p>\n\n        <div\n          className=\"absolute top-0 left-0 z-10\"\n          style={{\n            transform: `translate3d(${anchor.x}px, ${anchor.y}px, 0) translateX(-50%)`,\n            transition:\n              \"transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out\",\n            opacity: visible ? 1 : 0,\n            pointerEvents: visible ? \"auto\" : \"none\",\n            willChange: \"transform\",\n          }}\n        >\n          {/* A 36px pill wraps 28px controls at a 4px inset. The controls\n              resolve to a 14px radius, preserving the concentric curve. */}\n          <div\n            ref={barRef}\n            className=\"flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-card p-1 font-sans font-normal text-ink shadow-overlay\"\n            style={{\n              width:\n                mode === \"idle\" && hasPrompt && typingWidth\n                  ? typingWidth\n                  : undefined,\n              ...(visible\n                ? {\n                    animation:\n                      \"pop-in 220ms cubic-bezier(0.23,1,0.32,1) both\",\n                  }\n                : {}),\n            }}\n          >\n            <div\n              ref={contentRef}\n              className=\"flex w-fit shrink-0 items-center justify-center gap-0.5\"\n              style={{\n                width:\n                  mode === \"idle\" && hasPrompt && typingWidth\n                    ? typingWidth - 8\n                    : undefined,\n              }}\n            >\n            {busy && (\n              <span className=\"inline-flex h-7 items-center gap-1.5 whitespace-nowrap px-2.5 text-[12.5px] font-normal text-ink-2\">\n                <span\n                  className=\"size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2\"\n                  style={{ animation: \"spin 700ms linear infinite\" }}\n                />\n                {mode === \"thinking\" ? (\n                  <Shimmer className=\"text-[12.5px] font-normal\">\n                    {busyLabel}…\n                  </Shimmer>\n                ) : (\n                  <span>{busyLabel}…</span>\n                )}\n              </span>\n            )}\n\n            {mode === \"result\" && (\n              <>\n                <button\n                  type=\"button\"\n                  onClick={reset}\n                  className={primary}\n                >\n                  {icons.check}\n                  {copy.keep}\n                </button>\n                <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"h-7 shrink-0 gap-1 px-2.5 text-[12px] font-normal\" onClick={reset}>\n                  {icons.close}\n                  {copy.discard}\n                </Button>\n                <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n                <button\n                  type=\"button\"\n                  aria-label=\"Try again\"\n                  onClick={() => run(action)}\n                  className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink-2 active:scale-[0.96]\"\n                >\n                  {icons.retry}\n                </button>\n              </>\n            )}\n\n            {mode === \"idle\" && (\n              <>\n                <div\n                  className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                  style={{\n                    maxWidth: expanded\n                      ? 0\n                      : hasPrompt && typingWidth\n                        ? typingWidth - 40\n                        : 145,\n                    opacity: expanded ? 0 : 1,\n                    transform: expanded ? \"translateX(-8px)\" : \"translateX(0)\",\n                    transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                  }}\n                >\n                  <form\n                    className=\"flex h-7 shrink-0 items-center transition-[width] duration-400\"\n                    style={{\n                      width:\n                        hasPrompt && typingWidth ? typingWidth - 40 : 145,\n                      transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                    }}\n                    onSubmit={(event) => {\n                      event.preventDefault();\n                      run(prompt.trim() || \"Improve\");\n                    }}\n                  >\n                    <input\n                      value={prompt}\n                      onChange={(event) => {\n                        const next = event.target.value;\n                        if (!prompt.trim() && next.trim()) {\n                          setTypingWidth(\n                            Math.ceil(\n                              barRef.current?.getBoundingClientRect().width ??\n                                0,\n                            ),\n                          );\n                        } else if (!next.trim()) {\n                          setTypingWidth(null);\n                        }\n                        setPrompt(next);\n                      }}\n                      aria-label={copy.placeholder}\n                      placeholder={copy.placeholder}\n                      className=\"h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink placeholder:text-ink-3\"\n                    />\n                  </form>\n                </div>\n\n                <div\n                  className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                  style={{\n                    maxWidth: hasPrompt ? 0 : expanded ? 462 : 224,\n                    opacity: hasPrompt ? 0 : 1,\n                    transform: hasPrompt ? \"translateX(-8px)\" : \"translateX(0)\",\n                    transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                  }}\n                >\n                  {!expanded && (\n                    <span className=\"mx-1 h-4 w-px shrink-0 bg-line-strong\" />\n                  )}\n                  {actions.primary.map((item) => (\n                    <Button\n                      key={item.id}\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-7 shrink-0 gap-1 px-2.5 text-[12px] font-normal\"\n                      onClick={item.action ? () => run(item.action!) : undefined}\n                    >\n                      {item.icon}\n                      {item.id}\n                    </Button>\n                  ))}\n\n                  <div\n                    className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,margin] duration-400\"\n                    style={{\n                      maxWidth: expanded ? 262 : 0,\n                      opacity: expanded ? 1 : 0,\n                      marginLeft: expanded ? 2 : 0,\n                      transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                    }}\n                  >\n                  {actions.more.map((item) => (\n                    <Button\n                      key={item.id}\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      className=\"h-7 shrink-0 gap-1 px-2.5 text-[12px] font-normal\"\n                      onClick={item.action ? () => run(item.action!) : undefined}\n                    >\n                      {item.icon}\n                      {item.id}\n                    </Button>\n                  ))}\n                  </div>\n\n                  <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n                  <button\n                    type=\"button\"\n                    aria-label={expanded ? \"Show fewer actions\" : \"Show more actions\"}\n                    aria-expanded={expanded}\n                    onClick={() => setExpanded((value) => !value)}\n                    className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]\"\n                  >\n                    <span\n                      className=\"flex transition-transform duration-400\"\n                      style={{\n                        transform: expanded ? \"rotate(180deg)\" : \"rotate(0deg)\",\n                        transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                      }}\n                    >\n                      {icons.chevron}\n                    </span>\n                  </button>\n                </div>\n\n                <div\n                  className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                  style={{\n                    maxWidth: hasPrompt ? 30 : 0,\n                    opacity: hasPrompt ? 1 : 0,\n                    transform: hasPrompt ? \"scale(1)\" : \"scale(0.88)\",\n                    transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                  }}\n                >\n                  <button\n                    type=\"button\"\n                    aria-label=\"Send edit instruction\"\n                    onClick={() => run(prompt.trim())}\n                    className=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-card transition-[opacity,transform] duration-200 active:scale-[0.94]\"\n                  >\n                    {icons.send}\n                  </button>\n                </div>\n              </>\n            )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n    </div>\n  );\n}\n","type":"registry:component","target":"components/ward/SelectionActions.tsx"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/SelectionActions.tsx","access":"free"},"categories":["ai"],"type":"registry:component"}