Components/SelectionActions

SelectionActions

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

VerifiedSince 0.3.9

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.

Loading demo...

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.

API Reference

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? }.
activeActionIdstring—Id of the running action; picks the busy wording.
expandedboolean—v-model:expanded, the folded action group.
promptstring—v-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.
result—Replaces 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.

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 | null—Confines 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.

Overview

  • It springs in rather than fading up. The bar surfaces directly under the line the user just dragged across — it arrives at them — so it uses --tx-ease-spring (cubic-bezier(0.34, 1.56, 0.64, 1)) to carry scale(0.88) translateY(8px) past 1 and settle back. The family's shared bui-pop-in cannot do this: --tx-ease-out-strong decelerates hard but never crosses its end value, so it can only "appear", not "pop into reach". This component therefore takes a separate bui-spring-in, leaving the nine components that share bui-pop-in untouched. Under reduced motion the whole animation is dropped and the element rests on its end state (visible), not on the 0% frame.
  • 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.

Technologies

  • 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. selection-actions-motion.test.ts (7 cases) asserts the entrance contract against the compiled CSS: the spring curve's control points, the starting scale and offset the overshoot needs, that the replaced bui-pop-in is not reintroduced, that reduced motion stops every animation and leaves the element on its visible end state, and that the background comes from --tx-bui-surface rather than a literal — with a positive control confirming the compile produced animated rules at all.
  • Adapted from Beautiful UI (https://www.beautifului.dev), © 2026 Shane Levine, MIT.
查看源码
packages/tuffex/packages/components/src/selection-actions/index.ts
  • Upstream has no real selection. Its "selection" is a hardcoded highlighted <span>; window.getSelection() appears nowhere in the source. The tracking layer — useSelectionAnchor, the snapshot semantics, and the pointerdown focus guard — is a contract written for this port, not a restatement of upstream.
  • How it divides from TxMessageActions: that is the icon toolbar at the foot of a message (copy / regenerate / speak), with role="toolbar" and roving tabindex, no floating layer and no input. This is a rewrite entry point floating over a selection; the two do not share a focus model.
  • Known deviation: upstream drives the action groups' entrance with hand-tuned pixel widths (145 / 224 / 262 / 462 / 30). Those were measured against Inter at 13px and are wrong at any other face, so they are replaced by a measurement-free grid-template-columns: 0fr↔1fr collapse.
  • Known deviation: upstream's Explain is a dead button with no onClick; here it emits action like every other one.
  • Not covered: Safari and Firefox differ in caret behaviour after pointerdown is prevented. CI cannot exercise that, so verify on real browsers when integrating.