Components/SelectionActions

SelectionActions

A bar that floats beneath a text selection and hands the highlighted passage to an agent.

VerifiedSince 0.3.9

SelectionActions

Basic Usage

Rewriting a Selection

Select a sentence in the paragraph and the bar appears beneath the selection's last line. Four states: idlethinkingstreamingresult.

Loading demo...

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.

useSelectionAnchor and resolveSelectionPayload are runtime functions, not components. TxSelectionActions is 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-actions as shown above — they are not in the global component registry.

useSelectionAnchor Options

OptionTypeDefaultDescription
rootElement | nullConfines tracking to one subtree; omit to watch the whole document.
debouncenumber120Settle time for selectionchange, in ms. It fires every frame while dragging.
minLengthnumber1Minimum trimmed length.
disabledbooleanfalseStop reporting without unmounting.
ignoreElement[][]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

PropTypeDefaultDescription
selectionSelectionPayload | nullnull{ text, rects, range? }. Null retracts the bar.
state'idle' | 'thinking' | 'streaming' | 'result''idle'The host owns the machine; the component never calls a model.
actionsSelectionActionItem[]Explain / Improve plus folded Shorten / Tone / GrammarThe actions: { id, label, more?, busyLabel? }.
activeActionIdstringId of the running action; picks the busy wording.
expandedbooleanv-model:expanded, the folded action group.
promptstringv-model:prompt, the free-text instruction.
hidePromptbooleanfalseHide the text field and its send control.
placeholderstring'Describe edits'Field placeholder, reused as its accessible name.
ariaLabelstring'Selection actions'Accessible name of the bar.
keepLabel / discardLabelstring'Keep' / 'Discard'The two result-state buttons.
retryLabelstring'Try again'Accessible name of the retry control.
sendLabelstring'Send edit instruction'Accessible name of the send control.
expandLabel / collapseLabelstring'Show more actions' / 'Show fewer actions'Accessible names of the chevron.
busyLabelstring'Editing'Fallback busy wording for an action without its own.
offsetnumber8Distance from the selection's last line, in px.

Events

EventArgumentsDescription
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

SlotScopeDescription
action-icon{ action }Replaces an action's glyph. Required for a custom id.
busy{ label }Replaces the busy readout.
resultReplaces the keep / discard / retry cluster.

Exposed

MethodDescription
updatePosition()Reposition against the current selection.rects. A streaming host must call it — see below.
focusInput()Focus the free-text field.
elThe 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, so floating-ui has no element to observe and will not follow text reflow. Each delta of a streaming rewrite reflows the paragraph, so the host has to call updatePosition() 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 disableFlip to TxBaseAnchor: near the bottom of the viewport it will not jump above the selection, because changing sides mid-rewrite reads as a different control. shift still applies, so it is pushed back horizontally.
  • selection is 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 why useSelectionAnchor calls range.cloneRange().
  • The bar's root swallows pointerdown (preventDefault), so pressing a button neither moves focus nor destroys the selection. The handler exempts anything inside an input, textarea or contenteditable, matched with closest() 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 ignore or the bar dismisses itself. Focusing the prompt collapses the document selection, and useSelectionAnchor reads a collapse as the reader clearing their selection unless the bar holds focus. Pass the instance's el: ignore: () => [barRef.value?.el ?? null]. Do not reach for document.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 while prompt is empty.
  • The bar is role="group", not role="toolbar": it contains a text field, and the arrow keys belong to the caret.
  • thinking and streaming differ 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-motion in script and skips the tween. The state machine still advances; the width simply lands.
  • An empty prompt never emits submit.

Best Practices

  • Call updatePosition() right after the text update, in the same frame, rather than deferring it through setTimeout.
  • Always list the bar itself in useSelectionAnchor's ignore, or the moment a reader clicks the field the selection collapses and the bar disappears.
  • Call clear() after keep or discard, or the snapshot lingers and the bar never retracts.
  • Give custom actions a busyLabel — "Improving…" is far more informative than the generic "Editing…".
  • Scope root to the article container instead of the whole document, or any selection anywhere on the page pops the bar.
  • Set hidePrompt on 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's tabindex and aria-expanded, Explain emitting like every other action, the prompt gate and trimming, the busy wording and shimmer branch, all three result actions, and pointerdown being swallowed on the bar but not on the field. selection-actions-position.test.ts (2 cases) asserts end to end that updatePosition() reaches floating-ui's update. use-selection-anchor.test.ts (9 cases) covers empty selections, the minimum length, collapsed ranges, root confinement, and zero-sized rect filtering.
  • Adapted from Beautiful UI (https://www.beautifului.dev), © 2026 Shane Levine, MIT.
查看源码
packages/tuffex/packages/components/src/selection-actions/index.ts