SelectionActions
A bar that floats beneath a text selection and hands the highlighted passage to an agent.
SelectionActions
Basic Usage
Rewriting a Selection
Select a sentence in the paragraph and the bar appears beneath the selection's last line. Four states: idle → thinking → streaming → result.
Two Layers: Tracking and Presentation
The component only presents: give it a selection snapshot and a state, and it handles positioning, the width morph, and the action row. Where the selection comes from is the host's business.
useSelectionAnchor() is the matching tracking layer, built on useTextSelection from @vueuse/core, and suits ordinary document prose. For contenteditable, virtualised lists, or an iframe, construct a SelectionPayload yourself and feed it in.
import { resolveSelectionPayload, useSelectionAnchor } from '@talex-touch/tuffex/selection-actions'
const { selection, clear } = useSelectionAnchor({
root: articleRef, // track selections inside this subtree only
debounce: 120, // selectionchange fires every frame while dragging
minLength: 1, // a selection too short to be worth a bar
ignore: () => [barEl], // focus landing on the bar is not a deselection
})
resolveSelectionPayload() is the pure rule it uses internally, exported separately for testing or custom tracking.
useSelectionAnchorandresolveSelectionPayloadare runtime functions, not components.TxSelectionActionsis available as a global tag and can be written straight into a template, but these two must be imported explicitly from@talex-touch/tuffex/selection-actionsas shown above — they are not in the global component registry.
useSelectionAnchor Options
| Option | Type | Default | Description |
|---|---|---|---|
root | Element | null | — | Confines tracking to one subtree; omit to watch the whole document. |
debounce | number | 120 | Settle time for selectionchange, in ms. It fires every frame while dragging. |
minLength | number | 1 | Minimum trimmed length. |
disabled | boolean | false | Stop reporting without unmounting. |
ignore | Element[] | [] | Focus landing inside these does not count as a deselection. |
Returns { selection, clear }: selection is a Ref<SelectionPayload | null>, and clear() is called once the rewrite has been applied or dropped.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
selection | SelectionPayload | null | null | { text, rects, range? }. Null retracts the bar. |
state | 'idle' | 'thinking' | 'streaming' | 'result' | 'idle' | The host owns the machine; the component never calls a model. |
actions | SelectionActionItem[] | Explain / Improve plus folded Shorten / Tone / Grammar | The actions: { id, label, more?, busyLabel? }. |
activeActionId | string | — | Id of the running action; picks the busy wording. |
expanded | boolean | — | v-model:expanded, the folded action group. |
prompt | string | — | v-model:prompt, the free-text instruction. |
hidePrompt | boolean | false | Hide the text field and its send control. |
placeholder | string | 'Describe edits' | Field placeholder, reused as its accessible name. |
ariaLabel | string | 'Selection actions' | Accessible name of the bar. |
keepLabel / discardLabel | string | 'Keep' / 'Discard' | The two result-state buttons. |
retryLabel | string | 'Try again' | Accessible name of the retry control. |
sendLabel | string | 'Send edit instruction' | Accessible name of the send control. |
expandLabel / collapseLabel | string | 'Show more actions' / 'Show fewer actions' | Accessible names of the chevron. |
busyLabel | string | 'Editing' | Fallback busy wording for an action without its own. |
offset | number | 8 | Distance from the selection's last line, in px. |
Events
| Event | Arguments | Description |
|---|---|---|
action | ({ id, action, selection }) | A preset action was pressed, carrying the selection snapshot. |
submit | ({ prompt, selection }) | The field was submitted or send was pressed; prompt is trimmed. |
keep / discard | () | Keep or discard in the result state. |
retry | () | Retry in the result state. |
update:expanded | (expanded: boolean) | The folded group opened or closed. |
update:prompt | (prompt: string) | The field's content changed. |
Slots
| Slot | Scope | Description |
|---|---|---|
action-icon | { action } | Replaces an action's glyph. Required for a custom id. |
busy | { label } | Replaces the busy readout. |
result | — | Replaces the keep / discard / retry cluster. |
Exposed
| Method | Description |
|---|---|
updatePosition() | Reposition against the current selection.rects. A streaming host must call it — see below. |
focusInput() | Focus the free-text field. |
el | The bar's root element. Pass it to useSelectionAnchor's ignore. |
Interaction Contract
updatePosition()is a host responsibility, not an optional optimisation. The bar is anchored to a virtual reference, sofloating-uihas no element to observe and will not follow text reflow. Each delta of a streaming rewrite reflows the paragraph, so the host has to callupdatePosition()after updating the text or the bar stays at the old position. Window resize and scroll are handled by the component itself.- The anchor is the bottom of the selection's last line, centred on the whole selection. For a selection spanning three lines the bar lands under where the reader stopped, not under the block's midpoint.
- The bar does not flip. It passes
disableFliptoTxBaseAnchor: near the bottom of the viewport it will not jump above the selection, because changing sides mid-rewrite reads as a different control.shiftstill applies, so it is pushed back horizontally. selectionis a snapshot, not the live selection. Focusing the bar's text field clears the browser selection; the component holds the payload it was given, so the action still has a target. That is also whyuseSelectionAnchorcallsrange.cloneRange().- The bar's root swallows
pointerdown(preventDefault), so pressing a button neither moves focus nor destroys the selection. The handler exempts anything inside aninput,textareaorcontenteditable, matched withclosest()rather than by exact target: the field sits inside a<form>, so an identity check misses as soon as the pointer lands a pixel off the input and the prompt becomes unfocusable — clicking it sent focus to<body>and typing went nowhere. - Wire
ignoreor the bar dismisses itself. Focusing the prompt collapses the document selection, anduseSelectionAnchorreads a collapse as the reader clearing their selection unless the bar holds focus. Pass the instance'sel:ignore: () => [barRef.value?.el ?? null]. Do not reach fordocument.querySelector('.tx-bui-selection-actions')— it returns the first bar in the document, which is the wrong one as soon as a page has two. - Folded actions carry
tabindex="-1"while collapsed and stay out of the tab order; so does the send control whilepromptis empty. - The bar is
role="group", notrole="toolbar": it contains a text field, and the arrow keys belong to the caret. thinkingandstreamingdiffer in exactly one way — the former's label shimmers, the latter's is plain text.- The width morph runs through the Web Animations API, which CSS media queries cannot reach, so the component reads
prefers-reduced-motionin script and skips the tween. The state machine still advances; the width simply lands. - An empty
promptnever emitssubmit.
Best Practices
- Call
updatePosition()right after the text update, in the same frame, rather than deferring it throughsetTimeout. - Always list the bar itself in
useSelectionAnchor'signore, or the moment a reader clicks the field the selection collapses and the bar disappears. - Call
clear()afterkeepordiscard, or the snapshot lingers and the bar never retracts. - Give custom actions a
busyLabel— "Improving…" is far more informative than the generic "Editing…". - Scope
rootto the article container instead of the whole document, or any selection anywhere on the page pops the bar. - Set
hidePrompton read-only surfaces and keep just the preset actions.
Source
- Component source:
packages/tuffex/packages/components/src/selection-actions/src/TxSelectionActions.vue. - Composable:
packages/tuffex/packages/components/src/selection-actions/src/use-selection-anchor.ts. - Types:
packages/tuffex/packages/components/src/selection-actions/src/types.ts. - Verified coverage:
selection-actions.test.ts(21 cases) covers appearing with a selection,role="group", the anchor taking the last line's bottom and the whole selection's horizontal span,disableFlip, the folded group'stabindexandaria-expanded, Explain emitting like every other action, thepromptgate and trimming, the busy wording and shimmer branch, all three result actions, andpointerdownbeing swallowed on the bar but not on the field.selection-actions-position.test.ts(2 cases) asserts end to end thatupdatePosition()reachesfloating-ui'supdate.use-selection-anchor.test.ts(9 cases) covers empty selections, the minimum length, collapsed ranges,rootconfinement, and zero-sized rect filtering. - Adapted from Beautiful UI (https://www.beautifului.dev), © 2026 Shane Levine, MIT.