Selection actions
A floating toolbar on selected text: rewrite, shorten, translate or ask.
- Status
- Experimental
- Level
- Organism
- Category
- AI
- Adoption
- Not used yet
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";packages/canon/src/components/ai/selection-actions.tsximport { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { TextSelectIcon } from "lucide-react";import * as React from "react";export function Hero() { const id = React.useId(); const ref = React.useRef<HTMLTextAreaElement>(null); const sentence = "The $760.00 difference is the 2% early-payment discount in your terms, because we paid on day 8."; const [value, setValue] = React.useState( `Hi Aisha,\n\nThanks for calling about INV-20877. We paid it by ACH on Friday, September 25, for $18,240.00. ${sentence} The remittance advice is attached.\n\nBest,\nMaya Okafor`, ); const canned: Record<string, string> = { rewrite: "We paid on day 8, so the 2% early-payment discount in your terms applied. That's the $760.00 difference.", shorten: "The $760.00 is your 2% early-payment discount.", expand: `${sentence} Your terms are 2/10 net 30, so any invoice we pay within 10 days is reduced by 2%.`, tone: "Just so it's clear, the $760.00 is the 2% early-payment discount in your terms, since we paid on day 8.", translate: "La diferencia de $760.00 es el descuento del 2% por pronto pago de sus condiciones, porque pagamos el día 8.", ask: "The $760.00 difference is the 2% early-payment discount (2/10 net 30), applied because we paid on day 8.", }; return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <SelectionActions languages={["Spanish", "French"]} onAction={(actionId, text, options) => { if (text.trim() === sentence) return canned[actionId] ?? text; if (actionId === "shorten") { const words = text.trim().split(/\s+/); return `${words .slice( 0, Math.max(3, Math.ceil(words.length * 0.6)), ) .join(" ") .replace(/[,.;:]$/, "")}.`; } if (actionId === "expand") { return `${text.trim()} Reply to this email if anything still looks off.`; } if (actionId === "translate") return `[${options.language}] ${text}`; return text.trim().replace(/^./, (c) => c.toUpperCase()); }} > <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Reply to Halcyon</Label> <Textarea ref={ref} id={id} rows={9} value={value} onChange={(event) => setValue(event.target.value)} className="text-[13px] leading-relaxed" /> </div> </SelectionActions> <Button type="button" variant="outline" size="sm" className="self-start" onClick={() => { const field = ref.current; const start = value.indexOf(sentence); if (!field || start === -1) return; field.focus(); field.setSelectionRange(start, start + sentence.length); }} > <TextSelectIcon data-icon="inline-start" aria-hidden="true" /> Select the discount sentence </Button> </div> );}Usage#
Selection actions is a floating AI toolbar that appears over selected text inside whatever it wraps: a textarea, an input, a contenteditable or plain read-only text. It offers Rewrite, Shorten, Expand, Change tone, Translate and Ask, previews the result as a word diff, and only changes the text when the person accepts. In editable fields the replacement goes through the browser's own editing, so native undo still works. It's experimental and not used in the app yet; the common mistake would be wrapping a whole page in it, so every selected invoice number pops an AI toolbar.
When to use
- Around long-form text people write for suppliers: a remittance email draft, a ticket reply, a campaign message.
- Around read-only generated text that people may want to tighten before copying, such as a call summary, with
onApplyto store the change. - When the edit is local to a passage. Rewriting the whole field from a description is a different job.
When not to use
- To generate or rewrite a whole field from a short instruction. Use AI generate button
- For editing an agent's prompt, where the editor has its own inline AI. Use Prompt editor
- For a conversation about the text. Send it to Copilot instead. Use Thread
- Around tables, IDs, amounts or form fields with short values.
- For a toolbar of formatting controls on a selection. Use Toolbar
Preview, then accept
The One Filled Button Rule
The Tint Well Rule
Anatomy#
- Floating surface. Popover White, 12px corners, 4px padding and the overlay shadow, fixed 8px above the selection (below it when there's no room) and kept 8px inside the viewport.
- Actions. 28px text buttons with a 14px icon in Slate Meta: Rewrite, Shorten and Expand by default. A fluid Well Gray highlight follows the pointer and the arrow keys.
- Tone. A menu of tones. Hidden when
tonesis empty. - Translate. A menu of languages. Hidden when
languagesis empty. - Ask. Opens a one-line instruction field, Tell AI how to change this…, with a filled send button. Hidden with
allowAsk={false}. - Result. The action's name, then the diff or new text in a tint well, up to 192px tall before it scrolls.
- Try again. A ghost icon button that reruns the same request.
- Discard and Accept. Ghost Discard and a filled Accept. Accept reads Copy when the text can't be replaced.
Examples#
Read-only text with onApply
Select part of the summary. Accept calls onApply with the new text and the captured selection, and the page stores the change.
Call summary
Aisha Bello from Halcyon called about INV-20877, which was paid $760.00 short. Nora explained that the difference is the 2% early-payment discount in Halcyon's terms, and resent the remittance advice with the discount itemized. Aisha said she would update their ledger and didn't need a callback.
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ReadOnlyText() { const [summary, setSummary] = React.useState( "Aisha Bello from Halcyon called about INV-20877, which was paid $760.00 short. Nora explained that the difference is the 2% early-payment discount in Halcyon's terms, and resent the remittance advice with the discount itemized. Aisha said she would update their ledger and didn't need a callback.", ); return ( <div className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <h3 className="mb-1.5 text-sm font-semibold">Call summary</h3> <SelectionActions tones={[]} languages={[]} onAction={(actionId, text) => { const words = text.trim().split(/\s+/); if (actionId === "shorten") { return `${words .slice( 0, Math.max(3, Math.ceil(words.length * 0.55)), ) .join(" ") .replace(/[,.;:]$/, "")}.`; } if (actionId === "expand") { return `${text.trim()} The call lasted 2 minutes 14 seconds and ended without a transfer.`; } return text .trim() .replace( "didn't need a callback", "no callback is needed", ); }} onApply={(next, range) => { setSummary((current) => current.replace(range.text, next)); toast.add({ type: "success", title: "Summary updated" }); }} > <p className="text-sm leading-relaxed text-pretty text-foreground"> {summary} </p> </SelectionActions> </div> );}Custom actions and options
Replace the default actions, hide Change tone with an empty tones, limit Translate to two languages and turn off Ask.
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { BriefcaseIcon, SpellCheckIcon } from "lucide-react";import * as React from "react";export function CustomActions() { const id = React.useId(); const [value, setValue] = React.useState( "Hi Tomás, we dont have a current W-9 for Orchard Street so OS-4502 is on hold. Can you upload one before friday so it goes in the payment run.", ); return ( <div className="w-full max-w-xl text-left"> <SelectionActions actions={[ { id: "fix", label: "Fix grammar", pendingLabel: "Fixing grammar…", icon: <SpellCheckIcon />, diff: true, }, { id: "formal", label: "Make formal", pendingLabel: "Rewriting…", icon: <BriefcaseIcon />, diff: true, }, ]} tones={[]} languages={["Spanish", "Portuguese"]} allowAsk={false} onAction={(actionId, text, options) => { if (actionId === "fix") { return text .replace("dont", "don't") .replace("Street so", "Street, so") .replace("friday", "Friday") .replace(/run\.$/, "run?"); } if (actionId === "formal") { return text .replace(/^Hi Tomás, /, "Dear Tomás, ") .replace("Can you", "Could you please"); } return `[${options.language}] ${text}`; }} > <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Ticket reply</Label> <Textarea id={id} rows={4} value={value} onChange={(event) => setValue(event.target.value)} className="text-[13px] leading-relaxed" /> </div> </SelectionActions> </div> );}Async results, errors and containerRef
Return a Promise from onAction; a rejection shows the error with Try again. containerRef watches an element you already render instead of wrapping it.
Expand always fails here, to show the error state.
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function AsyncAndErrors() { const id = React.useId(); const scope = React.useRef<HTMLDivElement>(null); const [value, setValue] = React.useState( "Your payment for invoice INV-20931 is scheduled for Tuesday's ACH run on September 29. The remittance will be emailed the same day.", ); return ( <div className="w-full max-w-xl text-left"> <div ref={scope} className="flex flex-col gap-1.5"> <Label htmlFor={id}>Message to Northwind Freight</Label> <Textarea id={id} rows={3} value={value} onChange={(event) => setValue(event.target.value)} className="text-[13px] leading-relaxed" /> <p className="text-xs text-muted-foreground"> Expand always fails here, to show the error state. </p> </div> <SelectionActions containerRef={scope} tones={[]} languages={[]} allowAsk={false} onAction={(actionId, text) => new Promise<string>((resolve, reject) => { window.setTimeout(() => { if (actionId === "expand") reject(new Error("Model timeout")); else if (actionId === "shorten") resolve( "INV-20931 pays Tuesday, September 29, by ACH.", ); else resolve( text.replace( "Your payment for invoice", "Payment for", ), ); }, 1200); }) } /> </div> );}States#
import { makeRequest, SelectionError, SelectionPending, SelectionResult,} from "@oration/canon/components/ai/selection-panels";import { toast } from "@oration/canon/components/toast";export function StatesPreview() { return ( <div className="grid w-full gap-6 text-left md:grid-cols-[auto_1fr]"> <div className="flex flex-col items-start gap-6"> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Pending </span> <div className="w-fit rounded-xl bg-popover p-1 shadow-popover"> <SelectionPending label="Rewriting…" onCancel={() => toast.add({ title: "Edit cancelled" }) } /> </div> </div> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground">Error</span> <div className="w-fit rounded-xl bg-popover p-1 shadow-popover"> <SelectionError onRetry={() => toast.add({ title: "Trying again" })} onDiscard={() => toast.add({ title: "Edit discarded" }) } /> </div> </div> </div> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Result with a diff </span> <div className="w-fit rounded-xl bg-popover p-1 shadow-popover"> <SelectionResult request={makeRequest({ id: "rewrite", label: "Rewrite", diff: true, })} before="We paid it by ACH on Friday for $18,240.00." after="It went out by ACH on Friday, September 25, for $18,240.00." acceptLabel="Accept" onRetry={() => toast.add({ title: "Trying again" })} onDiscard={() => toast.add({ title: "Edit discarded" })} onAccept={() => toast.add({ title: "Selection replaced" }) } /> </div> </div> </div> );}| State | Treatment |
|---|---|
| Closed | No selection, or the selection is outside the container. |
| Menu | Text is selected: the toolbar springs in from 0.97 scale over 80ms. Screen readers hear AI actions available. Press Command J. once. |
| Ask | The toolbar becomes the instruction field, focused. |
| Pending | A pixel-grid loader and a shimmering label, such as Rewriting…, with Cancel. |
| Result | The preview with Try again, Discard and Accept. Accept is focused. |
| Error | A red alert icon, Couldn't finish that edit., Try again (focused) and Discard. |
| Item hover and focus | The fluid highlight sits under the item and its text turns ink; keyboard focus adds a 2px Focus Indigo ring at 50%. |
Behavior#
- It listens for selections inside
children(wrapped in a div) or inside the element atcontainerRef. Selecting with the pointer, Shift and the arrow keys, or ⌘A all open it. - With text selected, ⌘J (Ctrl+J elsewhere) opens the toolbar and moves focus to its first item. Arrow Left and Right, Home and End move between items.
- Escape closes the menu, steps back from Ask to the menu, or discards a pending or finished result and restores the selection.
onAction(actionId, selectedText, options)runs the request. Return a string or a Promise of one; a rejected Promise shows the error state. A synchronous result waitssimulateMs(700ms) so the pending state is visible.optionscarriestonefor Change tone,languagefor Translate andpromptfor Ask. Their action ids aretone,translateandask.- Accept in a textarea or input replaces the selection through
execCommand("insertText"), so ⌘Z undoes it, and raises Selection replaced with Undo. In a contenteditable it replaces the range. In read-only text it callsonApply(newText, selection); withoutonApplyit copies the result and toasts Copied to clipboard. - Clicking outside closes it. It follows the selection when the page scrolls or resizes. A newer request cancels an older one still in flight.
- The layer is portaled to the body and exits in 60ms.
Do and don't#
containerRef at it.onApply for read-only text you can update, so Accept means accept.onApply when people expect the change to stick. Accept quietly becomes Copy.Content#
- Action labels are single verbs or short verb phrases: Rewrite, Shorten, Fix grammar, Make formal.
pendingLabelis the verb in progress with an ellipsis: Rewriting…, Fixing grammar…. The default is Working on it….- Tone and language options are single words in sentence case: Professional, Friendly, Spanish.
- Results should read as the person's own text, in the same voice and person, with IDs and amounts unchanged.
Accessibility#
- The toolbar is
role="toolbar"named Edit selection with AI, with arrow-key movement between items. - Opening is announced once per selection through a polite live region: AI actions available. Press Command J. (Control J off Apple platforms).
- Focus moves into the layer when it matters: the first item on ⌘J, the field in Ask, Accept on a result, Try again on an error, and Cancel while pending if focus was already inside.
- The pending row is
role="status", the error isrole="alert", and the result is a group named Suggested edit. - Pointer presses inside the layer don't steal the selection, so the text stays highlighted while you choose.
- Discard and Escape restore the original selection, so keyboard users land back where they were.
| Keys | Action |
|---|---|
| ⌘J | With text selected, opens the toolbar and focuses it. Ctrl+J off Apple platforms. |
| ←→ | Moves between toolbar items. |
| Home | First item. |
| End | Last item. |
| Enter | Runs the focused action, or sends the Ask instruction. |
| Esc | Closes, steps back from Ask, or discards and restores the selection. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Floating surface |
shadow-popover | Overlay lift |
--muted | Fluid highlight, result well at 70% |
--muted-foreground | Resting items and icons, result label |
--primary | Accept and Send instruction |
--destructive | Error icon; removed words in the diff, struck through on a 10% tint |
--success | Added words in the diff, on a 15% tint |
--ring | Item focus ring at 50% |
spring.fast | Enter at 0.97 scale, 80ms |
exit.fast | Exit, 60ms |
text-shimmer | Pending label |
API reference#
SelectionActions
The floating toolbar and its scope. Also exported: the CapturedSelection, SelectionAction and SelectionActionOptions types.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
onActionRequired | (actionId: string, selectedText: string, options: { language?: string; tone?: string; prompt?: string }) => Promise<string> | string | No default | Produces the replacement text. |
children | React.ReactNode | No default | Content to watch, wrapped in a div. Omit when using containerRef. |
containerRef | React.RefObject<HTMLElement | null> | No default | Watch an existing element instead of wrapping children. |
actions | { id: string; label: string; pendingLabel?: string; icon?: React.ReactNode; diff?: boolean }[] | Rewrite, Shorten, Expand | diff previews the result as a word diff. |
languages | string[] | Spanish, French, German, Portuguese, Japanese, Hindi | Pass [] to hide Translate. |
tones | string[] | Professional, Friendly, Direct, Confident, Empathetic | Pass [] to hide Change tone. |
allowAsk | boolean | true | Shows Ask. |
onApply | (newText: string, range: CapturedSelection) => void | No default | Called on Accept. Required to replace read-only text. |
simulateMs | number | 700 | How long a synchronous onAction appears to work. |
className | string | No default | Merged onto the wrapper div when using children. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
It isn't used anywhere in the app. Fields get AIGenerateButton and the prompt editor has its own inline AI.
The result's Try again is an icon button with an aria-label but no tooltip.
Only field replacements get an Undo toast. Contenteditable relies on native undo, and onApply gets none, so callers must offer their own.
Accept on read-only text without onApply silently becomes Copy.
Replacement uses document.execCommand("insertText"), which is deprecated, with setRangeText as the fallback.
The panels it renders (SelectionToolbar, SelectionResult and the rest in selection-panels) are exported but undocumented; this page renders them statically only to show the anatomy and states.
Not checked: how it competes with the native selection menu on iOS and Android.