AI

AI Selection Menu

An Ask-AI row that floats over a text selection — action shortcuts, submenus, a free-form question field, one captured excerpt and a keyboard path in.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ChevronDown,
  CornerDownLeft,
  Languages,
  Lightbulb,
  ListMinus,
  Loader2,
  MessageCircleQuestion,
  PenLine,
  Sparkles,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ai-selection-menu.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AiSelectionMenu" component (lucide-react
for icons, no popover library): a floating "Ask AI" row that appears over any
text selection made inside the region it wraps, and reports the chosen action to
the consumer. It runs nothing itself.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  children. The root is a position:relative scope; `children` render inside an
  inner content wrapper and the floating row is that wrapper's SIBLING, so
  reading the selection can never pick up the word "Summarize" from the row
  hovering over it.
- children: the text this instance watches.
- actions?: AiSelectionAction[] — default summarize / explain / translate ▸ /
  rewrite ▸ / ask.
    AiSelectionAction = { type: string; label: string; icon?: ReactNode;
      options?: { value: string; label: string; description?: string }[];
      ask?: boolean; askPlaceholder?: string; disabled?: boolean }
  `options` non-empty turns the item into a submenu trigger; `ask` turns it into
  a disclosure that reveals a one-line question field.
- onAction: (request, controls) => void — required.
    request = { type, selectionText, option?, query?, truncated, chars }
    controls = { close(): void }
- onOpenChange?: (open: boolean) => void.
- label?: string (default "Ask AI") — the brand chip and the toolbar's
  accessible name.
- maxLength?: number (default 4000) / minLength?: number (default 3).
- placement?: "auto" | "top" | "bottom" (default "auto").
- closeOnAction?: boolean (default true) — false keeps the row up so it can show
  progress; the consumer closes it later through controls.close().
- pending?: boolean — the row shows a spinner and "Working…", every item goes
  aria-disabled (the question field aria-disabled + readOnly), and the card
  carries aria-busy. Never the native `disabled` attribute: it would blur the
  item the reader just pressed and drop focus to <body> mid-request. The
  handlers re-check `pending` instead, and the roving tab stop stays where it is
  while the request is in flight.
- includeEditable?: boolean (default false) — also offer the row for selections
  inside a contenteditable in this scope. Selections inside input / textarea /
  select are ALWAYS ignored: they belong to that field.
- disabled?: boolean — render no offer at all (a message that is still
  streaming, a redacted passage). Honour it by not rendering, not by an effect
  that clears state, so it takes effect in the same commit.
- Also export normalizeSelectionText(raw, maxLength) and
  DEFAULT_AI_SELECTION_ACTIONS so a consumer can extend the default row.

Behavior — detection
- One document-level listener set per scope: `selectionchange` (coalesced into a
  single requestAnimationFrame — it fires once per arrow key of a Shift+Arrow
  selection and once per pointer move of a drag), plus capture-phase pointerdown
  / pointerup / pointercancel and window blur.
- Drag gate: while a pointer is down the selection is not a decision yet, so
  evaluation is suspended and re-run on pointerup. A drag released outside the
  window never delivers pointerup — window blur must reopen the gate or no
  selection will ever produce an offer again.
- Resolve the scope with `range.commonAncestorContainer`: it is the deepest node
  holding BOTH endpoints, so "inside the scope" also proves "both ends are
  inside the scope". A selection that spills into the page around it is ignored
  instead of quietly sending half of it.
- Multi-range selections (Firefox Ctrl+drag): use the LAST range and that
  range's own text, because the row is positioned against that range only.
- Ignore ranges under minLength and ranges whose bounding box is 0×0 (a run of
  collapsed whitespace has nothing to point at).
- Geometry is stored SCOPE-relative (x = box centre, top, bottom, scope width),
  so scrolling the page or an ancestor container moves the row with the text for
  free. Keep the viewport-space top of the box for the flip decision only, and
  exclude it from the "did the anchor really change?" comparison, otherwise the
  row hops from above the selection to below it while the page scrolls.
- Re-measure through a ResizeObserver on the scope: in a chat the answer above
  can still be streaming, so the selected paragraph moves with no scroll and no
  resize event.

Behavior — the excerpt
- Capture the text at OFFER time and never read it back off the DOM afterwards.
  Focusing the question field collapses the page selection; a payload derived at
  click time would be empty exactly when the reader asked for the most work.
- normalizeSelectionText does NOT collapse whitespace — indentation and line
  breaks are meaning when the selection is code or a list, and this string is
  going into a prompt, not into a chip label. Normalize CRLF, drop spaces before
  a newline, trim the ends.
- Over maxLength: cut at the last word boundary in the tail unless that throws
  away more than a fifth of the budget (then cut hard), set truncated, keep the
  original length in `chars`, and show one muted line — "Sending the first N of
  M selected characters". Never silently drop the tail: the reader must know
  what the model is actually being given.

Behavior — the three locks
- Drag gate (above): no offer while the pointer is down.
- Latch: the moment focus or a pointer lands inside the row, detection is
  suspended and the anchor is frozen. Without it, focusing the question field
  collapses the selection and the row deletes itself mid-question.
- One-shot lock: acting on a range or dismissing it with Escape marks that
  range's signature as settled, so the row cannot pop straight back up over a
  selection the browser is still showing. The lock is released when the
  selection collapses OR on the next pointerdown outside the row — a new gesture
  is a new intention, and re-selecting the same sentence must offer it again.

Behavior — keyboard
- One Tab after an offer appears is captured document-wide and routed into the
  row (remember document.activeElement first). A floating row is not reliably
  next in the reading order of the selection, so relying on sequential focus
  navigation strands keyboard users. Hijack it EXACTLY once per offer: capturing
  every Tab that arrives from outside bounces focus straight back in the moment
  the reader tabs out, which turns a floating row into a focus trap. Once focus
  is inside, Tab behaves natively again and leaves.
- The row is role="toolbar" with a roving tabindex: exactly one item is tabbable,
  ArrowLeft / ArrowRight wrap, Home / End jump, disabled items are skipped.
  Moving the tab stop closes any open submenu or question field.
- ArrowDown (or Enter / Space, which a <button> fires natively) opens the item's
  submenu, role="menu" with role="menuitem" rows and its own ArrowUp/Down/Home/
  End; Escape or ArrowLeft returns to the trigger with the offer still standing
  — closing a submenu is not the same decision as declining the menu.
- Escape on the row dismisses the offer, restores focus to where it came from,
  and does NOT clear the highlight: the reader said "not this", not "unselect".
  A document-level Escape handler covers the mouse case (nothing focused), and
  must ignore events originating inside the row so the submenu keeps its own.
- The question field submits on Enter but only when
  event.nativeEvent.isComposing is false, or an IME candidate window turns half
  a Japanese word into a question.
- One sr-only role="status" node per offer announces that actions are available
  and how to reach them; the wording is constant, so it is spoken once per offer
  instead of on every keystroke of a Shift+Arrow selection.

Behavior — positioning
- Wrapper: absolute, left = clamped selection centre, -translate-x-1/2, and
  -translate-y-full when placed above so the card grows upwards on screen while
  it grows downwards in the DOM. The anchor cannot drift when a row is added.
- Clamp left in a layout effect once the row has a width, keeping EDGE_MARGIN
  from both scope edges; if the row is wider than the scope, centre it instead.
  A layout effect runs before paint, so the corrected position is the first one
  the reader ever sees.
- Choose above vs below from the height the menu CAN reach (row + question row
  if any action declares ask + truncation note), not the height it has right
  now. Measuring the live card flips the side the moment the question field
  opens and throws the whole row across the selection mid-interaction.
- The submenu opens downwards from the card, or upwards when the card sits above
  the selection so it never covers the very text it is about to act on; align it
  with its trigger via offsetLeft, then pull it back inside the card's width.
- Cancel mousedown on the row so the caret is not moved and the highlight
  survives — with ONE exception: the question field must get the default, it
  needs focus and a caret. Everything else already has the excerpt.

Behavior — firing
- Plain item: fire immediately. Submenu item: fire with `option`. Question field:
  fire with the trimmed `query`, empty is a no-op and the send button reads
  aria-disabled. Every path resets the submenu, the field and the draft query.
- Then close (closeOnAction) or stay up for the consumer's progress state. Key
  the floating row on the anchor signature: a new range is a new offer, and
  remounting resets the roving index, the open submenu and a half-typed question
  in one line.
- Cleanup on unmount: cancel the frame, remove all four listeners plus window
  blur, disconnect the observer.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the card and
  the submenu, border for the hairlines and separators, hover:bg-accent /
  hover:text-accent-foreground for items, text-muted-foreground for the brand
  chip, descriptions and the truncation note, text-primary for the sparkle,
  bg-primary / text-primary-foreground for the send button, ring for every
  focus-visible ring. No hex, no rgb(), no oklch().
- The card is w-max with a max width, and both the brand row and the toolbar
  wrap, so a narrow scope gets two short lines instead of a horizontally
  overflowing bar.
- The pop-in keyframes ship in a React 19 hoisted <style href precedence> and
  are motion-reduce:[animation:none]; the chevron rotation and colour
  transitions are motion-reduce:transition-none. Nothing about reaching, opening
  or firing an action depends on animation.
- aria-expanded on submenu and question triggers, aria-haspopup="menu" on
  submenu triggers only (a disclosure field is not a menu), aria-busy on the
  card while pending, aria-hidden on the sparkle, the separator and icon slots.

Customization levers
- The action set is the product: replace `actions` wholesale (Explain / Add
  types / Find bugs for code, Translate ▸ languages for a reader, Rewrite ▸ tones
  for a composer) or spread DEFAULT_AI_SELECTION_ACTIONS and append. `type` is
  yours — it is the discriminant your handler switches on.
- Ask field: drop the ask action for a pure one-click row, or make it the only
  action for a minimal "Ask AI" affordance; askPlaceholder carries the domain.
- Budget: maxLength decides what the model is given; lower it hard for expensive
  models, raise it for whole-article summaries. minLength kills stray
  double-click offers.
- placement="bottom" for scopes that live at the top of a scroll container;
  keep "auto" everywhere else. GAP, EDGE_MARGIN and the reserved row heights are
  the only geometry constants.
- Density: the item is h-7 / text-xs / gap-1.5 and the card is rounded-lg —
  scale those three for a chunkier bar. Swap the brand chip for your own mark,
  or delete it and let the actions speak.
- Progress: closeOnAction={false} + pending renders the busy row; call
  controls.close() when the answer lands. With closeOnAction left true, wire the
  result to a side panel, a rewrite diff or a toast — the menu deliberately owns
  no result surface.
- includeEditable={true} inside an editor, and pair it with your own
  formatting toolbar rather than replacing it: this row is about meaning, that
  one is about markup.

Concepts

  • Capture at offer time — the excerpt is frozen into the offer the moment the row appears, because every way out of the row moves focus and the question field collapses the page selection outright; reading the DOM at click time would hand the model an empty string exactly when the reader asked for the most work.
  • Drag gate — nothing is offered while a pointer is down: mid-drag the selection is a gesture in progress, and a row that chased the pointer across the paragraph would be unclickable; pointerup, pointercancel and window blur are all treated as "the gesture ended".
  • Latch on interaction — once focus or a press lands inside the row, selection detection switches off and the anchor freezes, so typing a question cannot delete the very menu it is being typed into.
  • One-shot lock — a range that was acted on or dismissed is remembered by signature so the row cannot pop straight back up over a highlight the browser is still showing; the lock lifts when the selection collapses or when the next press outside the row starts a new intention.
  • Tab into the toolbar — a floating row is not reliably next in the reading order after a selection, so the first Tab of each offer (and only the first, or the row becomes a focus trap) is captured and routed into a roving-tabindex toolbar; the next Tab leaves natively, and Escape returns focus to where it came from without clearing the highlight.
  • Reserved-height flip — above-or-below is decided from the tallest state the card can reach, not its current height, so opening the question field never throws the whole row across the selection mid-interaction.
  • No result surface — the component reports {type, selectionText, option, query} and stops there; the answer belongs to a panel, a diff or a toast you own, which is what keeps one row usable for summarizing, translating and rewriting alike.

On This Page