From b477f097b121c194fc7c281dbd12de3e4858b933 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 5 Aug 2026 13:52:37 -0700 Subject: [PATCH 01/18] Store the full selected range in TokenFieldValue --- .../@react-spectrum/ai/src/PromptField.tsx | 7 +- .../ai/stories/PromptField.stories.tsx | 4 +- .../test/TokenField.browser.test.tsx | 18 ++++ .../test/utils/tokenFieldBrowserUtils.tsx | 2 +- .../src/tokenfield/useTokenField.ts | 94 ++++++++++--------- .../exports/useTokenFieldState.ts | 1 + .../src/tokenfield/TokenFieldValue.ts | 66 ++++++++++--- 7 files changed, 129 insertions(+), 63 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index d9306e539e4..3c1704c2832 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -676,7 +676,7 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { // to be inaccurate let finalPrompt = buildVoicePrompt(basePromptRef.current, transcript); inputRef.current.focus(); - setTokenFieldSelection(inputRef.current, finalPrompt.caretPosition, finalPrompt.caretPosition); + setTokenFieldSelection(inputRef.current, finalPrompt.selectedRange); setPrompt(finalPrompt); }); @@ -822,16 +822,17 @@ function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[ setTimeout(() => { if (inputRef.current && pendingCaret.current) { let position = pendingCaret.current; + let range = new TokenFieldValue.SelectedRange(position); pendingCaret.current = null; inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late - setTokenFieldSelection(inputRef.current, position, position); + setTokenFieldSelection(inputRef.current, range); // the above focus and setCursor call can cause the internally tracked caret position to be reset incorrectly // seemingly due to TokenField's isProgrammaticSelectionChange being flipped to false by setCursor and thus reset to 0 by the .focus // fix this by resetting to proper position below // happens when injecting multiple tokens one after another via + menu - setPrompt(value => value.withCaretPosition(position)); + setPrompt(value => value.withSelectedRange(range)); } }, 400); } diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e8c004ba22d..e76902550b4 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -277,8 +277,8 @@ let prompt3Base = new PromptFieldValue([ ]); let prompts = [ - prompt1.withCaretPosition(atEnd(prompt1)), - prompt2.withCaretPosition(atEnd(prompt2)), + prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), + prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), prompt3Base.replaceRange( atEnd(prompt3Base), atEnd(prompt3Base), diff --git a/packages/react-aria-components/test/TokenField.browser.test.tsx b/packages/react-aria-components/test/TokenField.browser.test.tsx index 062a786665d..a5e0d6b7f7b 100644 --- a/packages/react-aria-components/test/TokenField.browser.test.tsx +++ b/packages/react-aria-components/test/TokenField.browser.test.tsx @@ -1135,5 +1135,23 @@ describeOrSkip('TokenField browser interactions', () => { } await waitForFieldText(getValue, 'b'); }); + + it('restores the selection when undoing a replacement', async () => { + let list = segments(text('abcde')); + let {textbox, getValue} = await renderControlledTokenField(list); + let el = textbox.element(); + await focusField(textbox); + // Select "bcd". + setFieldSelection(el, {index: 0, offset: 1}, {index: 0, offset: 4}); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + // Replace the selection by typing. + await userEvent.keyboard('X'); + await waitForFieldText(getValue, 'aXe'); + // Undo restores both the text and the selection that was replaced. + let mod = modKey(); + await userEvent.keyboard(`{${mod}>}z{/${mod}}`); + await waitForFieldText(getValue, 'abcde'); + await waitForSelection(textbox, {index: 0, offset: 1}, {index: 0, offset: 4}); + }); }); }); diff --git a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx index e9a56002477..d066980ddf9 100644 --- a/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx +++ b/packages/react-aria-components/test/utils/tokenFieldBrowserUtils.tsx @@ -102,7 +102,7 @@ export async function focusField(locator: Locator) { } export function setFieldSelection(textboxEl: Element, start: Position, end: Position): void { - setTokenFieldSelection(textboxEl, start, end); + setTokenFieldSelection(textboxEl, new TokenFieldValue.SelectedRange(start, end)); } /** diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index aef7c40d9f2..228c9400920 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -26,6 +26,7 @@ import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import { Position, + SelectedRange, TokenFieldProps, TokenFieldSegment, TokenFieldState, @@ -175,19 +176,19 @@ export function useTokenField( nextValue.current = value; }); - let caretPosition = useRef(null); + let selectedRange = useRef(null); useLayoutEffect(() => { if ( ref.current && - value.caretPosition && + value.selectedRange && !state.isComposing && - value.caretPosition !== caretPosition.current + value.selectedRange !== selectedRange.current ) { // Only move the caret when the field is already focused. if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { - setCursor(ref.current, value.caretPosition); + setTokenFieldSelection(ref.current, value.selectedRange); } - caretPosition.current = value.caretPosition; + selectedRange.current = value.selectedRange; } }); @@ -393,21 +394,24 @@ export function useTokenField( // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. - if (window.getSelection()?.isCollapsed) { - let [start, end] = getSelection(ref.current!)!; - if (start.offset === 0) { - let segment = value.segments[start.index]; + let range = getSelectedRange(ref.current!)!; + if (range.isCollapsed) { + if (range.current.offset === 0) { + let segment = value.segments[range.current.index]; if (segment?.type !== 'token') { - segment = value.segments[start.index - 1]; + segment = value.segments[range.current.index - 1]; } if (segment?.type === 'token') { announce(segment.text, 'assertive'); } - - // Update the caret position in the value. - state.setValue(value => value.withCaretPosition(end)); } } + + // Update the caret position in the value. Also update the ref so the layout + // effect does not re-apply this selection back to the DOM, which would clobber + // the browser's native selection direction (e.g. directionless double-click). + selectedRange.current = range; + state.setValue(value => value.withSelectedRange(range)); }); // Override the default triple click behavior to ensure that tokens get selected. @@ -423,7 +427,7 @@ export function useTokenField( let end = value.findLineBoundary(selection[1], TokenFieldValue.Direction.Forward); if (start && end) { e.preventDefault(); - setTokenFieldSelection(ref.current!, start, end, true); + setTokenFieldSelection(ref.current!, new TokenFieldValue.SelectedRange(start, end), true); } } }); @@ -613,6 +617,16 @@ export function getSelection(container: Element): [Position, Position] | null { return rangeToPositions(container, range); } +export function getSelectedRange(container: Element) { + let selection = window.getSelection(); + if (!selection || !selection.anchorNode || !selection.focusNode) { + return null; + } + let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset); + let current = getPosition(container, selection.focusNode, selection.focusOffset); + return new TokenFieldValue.SelectedRange(anchor, current); +} + function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { let start = getPosition(container, range.startContainer, range.startOffset); let end = getPosition(container, range.endContainer, range.endOffset); @@ -666,21 +680,23 @@ function getPosition(container: Element, node: Node, offset: number): Position { let isProgrammaticSelectionChange = Symbol('isProgrammaticSelectionChange'); function setCursor(root: Element, pos: Position, fireEvent = false) { - setTokenFieldSelection(root, pos, pos, fireEvent); + setTokenFieldSelection(root, new TokenFieldValue.SelectedRange(pos), fireEvent); } export function setTokenFieldSelection( root: Element, - start: Position, - end: Position, + selectedRange: SelectedRange, fireEvent = false ) { let selection = window.getSelection(); if (selection) { - let range = createDOMRange(root, start, end); + // Use setBaseAndExtent to preserve the selection direction. A plain Range + + // addRange always produces a forward selection and collapses when the + // anchor comes after the current position (backward selections). + let [anchorNode, anchorOffset] = getDOMPosition(root, selectedRange.anchor); + let [focusNode, focusOffset] = getDOMPosition(root, selectedRange.current); root[isProgrammaticSelectionChange] = !fireEvent; - selection.removeAllRanges(); - selection.addRange(range); + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); } } @@ -690,33 +706,27 @@ export function tokenFieldPositionToDOMRange(root: Element, pos: Position): Rang function createDOMRange(root: Element, start: Position, end: Position): Range { let range = document.createRange(); - let startChild = root.childNodes[start.index]; - if (!startChild) { - range.setStart(root, Math.min(root.childNodes.length, start.index)); - } else if (startChild.nodeType === Node.ELEMENT_NODE) { - // Place the cursor outside the token wrapper element. - if (start.offset > 0) { - range.setStartAfter(startChild); - } else { - range.setStartBefore(startChild); - } - } else { - range.setStart(startChild, start.offset); - } + let [startContainer, startOffset] = getDOMPosition(root, start); + let [endContainer, endOffset] = getDOMPosition(root, end); + range.setStart(startContainer, startOffset); + range.setEnd(endContainer, endOffset); + return range; +} - let endChild = root.childNodes[end.index]; - if (!endChild) { - range.setEnd(root, Math.min(root.childNodes.length, end.index)); - } else if (endChild.nodeType === Node.ELEMENT_NODE) { - if (end.offset > 0) { - range.setEndAfter(endChild); +function getDOMPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (!child) { + return [root, Math.min(root.childNodes.length, pos.index)]; + } else if (child.nodeType === Node.ELEMENT_NODE) { + // Place the cursor outside the token wrapper element. + if (pos.offset > 0) { + return [root, pos.index + 1]; } else { - range.setEndBefore(endChild); + return [root, pos.index]; } } else { - range.setEnd(endChild, end.offset); + return [child, pos.offset]; } - return range; } function isSamePosition(a: Position, b: Position): boolean { diff --git a/packages/react-stately/exports/useTokenFieldState.ts b/packages/react-stately/exports/useTokenFieldState.ts index e6ad50ac607..98e3a375c20 100644 --- a/packages/react-stately/exports/useTokenFieldState.ts +++ b/packages/react-stately/exports/useTokenFieldState.ts @@ -19,5 +19,6 @@ export type { TokenSegment, TextSegment, Position, + SelectedRange, TokenFieldValueOptions } from '../src/tokenfield/TokenFieldValue'; diff --git a/packages/react-stately/src/tokenfield/TokenFieldValue.ts b/packages/react-stately/src/tokenfield/TokenFieldValue.ts index dd8ddd1c3c2..9c1361e6801 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -31,13 +31,48 @@ export interface Position { offset: number; } +/** Represents a text selection in a TokenField. */ +export class SelectedRange { + /** The anchor position. */ + anchor: Position; + /** The current (i.e. caret) position. */ + current: Position; + + /** + * Creates a new selection range. If only a single position is provided, the selection is + * collapsed. + */ + constructor(anchor: Position, current: Position = anchor) { + this.anchor = anchor; + this.current = current; + } + + /** Whether the selection is collapsed to a single caret position. */ + get isCollapsed() { + return this.anchor.index === this.current.index && this.anchor.offset === this.current.offset; + } + + /** Returns whether this selection is equal to another. */ + isEqual(other: SelectedRange) { + if (this === other) { + return true; + } + return ( + this.anchor.index === other.anchor.index && + this.anchor.offset === other.anchor.offset && + this.current.index === other.current.index && + this.current.offset === other.current.offset + ); + } +} + enum Direction { Forward = 1, Backward = -1 } export interface TokenFieldValueOptions { - caretPosition?: Position | null; + selectedRange?: SelectedRange | null; } /** @@ -45,11 +80,12 @@ export interface TokenFieldValueOptions { */ export class TokenFieldValue { static readonly Direction = Direction; + static readonly SelectedRange = SelectedRange; /** The text and token segments in the list. */ readonly segments: readonly TokenFieldSegment[]; - /** The caret position. */ - caretPosition: Position = {index: 0, offset: 0}; + /** The selected range. */ + selectedRange: SelectedRange; // Linked list representing the undo/redo history. private previous: this | null = null; private next: this | null = null; @@ -58,7 +94,7 @@ export class TokenFieldValue { /** Create a new list with the given segments. */ constructor(tokens: readonly TokenFieldSegment[], options?: TokenFieldValueOptions) { this.segments = tokens; - this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; + this.selectedRange = options?.selectedRange ?? new SelectedRange({index: 0, offset: 0}); } protected createFieldValue(segments: readonly TokenFieldSegment[]): this { @@ -69,17 +105,18 @@ export class TokenFieldValue { return new Constructor(segments); } + get caretPosition(): Position { + return this.selectedRange.current; + } + /** Create a new list with the caret position set to the given position. */ - withCaretPosition(caretPosition: Position): this { - if ( - this.caretPosition.index === caretPosition.index && - this.caretPosition.offset === caretPosition.offset - ) { + withSelectedRange(selectedRange: SelectedRange): this { + if (this.selectedRange.isEqual(selectedRange)) { return this; } let result = this.createFieldValue(this.segments); - result.caretPosition = caretPosition; + result.selectedRange = selectedRange; result.previous = this.previous; result.next = this.next; result.isCoalescing = this.isCoalescing; @@ -174,14 +211,14 @@ export class TokenFieldValue { appendSegments(newSegments, this.segments.slice(end.index + 1)); let segments = this.createFieldValue(newSegments); - segments.caretPosition = caret; + segments.selectedRange = new SelectedRange(caret); segments.isCoalescing = coalesce; if (this.isCoalescing && coalesce && this.previous) { segments.previous = this.previous; segments.previous.next = segments; } else { segments.previous = this; - this.caretPosition = end; + this.selectedRange = new SelectedRange(start, end); this.next = segments; } return segments; @@ -304,8 +341,7 @@ export class TokenFieldValue { ); } - this.caretPosition = position; - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Delete text to the next or previous line break. */ @@ -324,7 +360,7 @@ export class TokenFieldValue { ); } - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Create a new list containing a subset of the segments. */ From 293637ba32cd533c18edd8bf04f6ea2e99989ba0 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 13:05:51 -0700 Subject: [PATCH 02/18] Improve token announcement and selection behavior --- .../react-aria/src/tokenfield/useToken.ts | 6 +- .../src/tokenfield/useTokenField.ts | 83 +++++++++++++------ 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index 9f51ed2ec75..15c9547b5b8 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -36,12 +36,12 @@ export function useToken( useEvent(useRef(typeof document !== 'undefined' ? document : null), 'selectionchange', () => { let selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || !ref.current) { + if (!selection || !ref.current) { return; } - let range = selection.getRangeAt(0); - if (!range.collapsed && range.intersectsNode(ref.current)) { + let range = selection.rangeCount === 0 ? null : selection.getRangeAt(0); + if (!range?.collapsed && range?.intersectsNode(ref.current)) { setSelected(true); } else { setSelected(false); diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 228c9400920..4398a1566d1 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -24,6 +24,7 @@ import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; +import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import { Position, SelectedRange, @@ -178,15 +179,11 @@ export function useTokenField( let selectedRange = useRef(null); useLayoutEffect(() => { - if ( - ref.current && - value.selectedRange && - !state.isComposing && - value.selectedRange !== selectedRange.current - ) { + if (ref.current && !state.isComposing && value.selectedRange !== selectedRange.current) { // Only move the caret when the field is already focused. if (ref.current === getActiveElement(getOwnerDocument(ref.current))) { setTokenFieldSelection(ref.current, value.selectedRange); + announceToken(value); } selectedRange.current = value.selectedRange; } @@ -394,19 +391,13 @@ export function useTokenField( // When the cursor moves next to a token, announce it. // Otherwise the screen reader will only announce the first/last character. - let range = getSelectedRange(ref.current!)!; - if (range.isCollapsed) { - if (range.current.offset === 0) { - let segment = value.segments[range.current.index]; - if (segment?.type !== 'token') { - segment = value.segments[range.current.index - 1]; - } - if (segment?.type === 'token') { - announce(segment.text, 'assertive'); - } - } + let range = getSelectedRange(ref.current!); + if (!range) { + return; } + announceToken(value, range); + // Update the caret position in the value. Also update the ref so the layout // effect does not re-apply this selection back to the DOM, which would clobber // the browser's native selection direction (e.g. directionless double-click). @@ -414,6 +405,21 @@ export function useTokenField( state.setValue(value => value.withSelectedRange(range)); }); + // Clear selection on blur. + useEvent(ref, 'blur', e => { + if (!e.isTrusted) { + return; + } + + let selection = window.getSelection(); + if (ref.current && selection && selection.containsNode(ref.current, true)) { + selection.removeAllRanges(); + state.setValue(value => + value.withSelectedRange(new TokenFieldValue.SelectedRange({index: 0, offset: 0})) + ); + } + }); + // Override the default triple click behavior to ensure that tokens get selected. // Some browsers only select the text between tokens instead of the entire line. useEvent(ref, 'mousedown', e => { @@ -619,21 +625,27 @@ export function getSelection(container: Element): [Position, Position] | null { export function getSelectedRange(container: Element) { let selection = window.getSelection(); - if (!selection || !selection.anchorNode || !selection.focusNode) { + if ( + !selection || + !selection.anchorNode || + !selection.focusNode || + !nodeContains(container, selection.anchorNode) || + !nodeContains(container, selection.focusNode) + ) { return null; } - let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset); - let current = getPosition(container, selection.focusNode, selection.focusOffset); + let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset, false); + let current = getPosition(container, selection.focusNode, selection.focusOffset, true); return new TokenFieldValue.SelectedRange(anchor, current); } function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { - let start = getPosition(container, range.startContainer, range.startOffset); - let end = getPosition(container, range.endContainer, range.endOffset); + let start = getPosition(container, range.startContainer, range.startOffset, false); + let end = getPosition(container, range.endContainer, range.endOffset, true); return [start, end]; } -function getPosition(container: Element, node: Node, offset: number): Position { +function getPosition(container: Element, node: Node, offset: number, isRangeEnd = false): Position { if (node === container) { return {index: offset, offset: 0}; } @@ -650,7 +662,7 @@ function getPosition(container: Element, node: Node, offset: number): Position { let endOffset = 0; if (originalNode === tokenNode) { // Cursor is inside the token. - atEnd = offset > 0; + atEnd = isRangeEnd || offset > 0; } else if (originalNode === node) { // Cursor is inside the wrapper element. atEnd = offset > 1; @@ -842,3 +854,26 @@ function trackMutations(element: Element) { } }; } + +function announceToken(value: TokenFieldValue, range = value.selectedRange) { + if (range.isCollapsed) { + // Announce adjacent tokens. + let segment = value.segments[range.current.index]; + if (segment && segment.type !== 'token') { + if (range.current.offset === 0) { + segment = value.segments[range.current.index - 1]; + } else if (range.current.offset === segment.text.length) { + segment = value.segments[range.current.index + 1]; + } + } + if (segment?.type === 'token') { + announce(segment.text, 'assertive'); + } + } else { + // Announce token if it is the only thing selected. + let selected = value.slice(range.anchor, range.current).segments; + if (selected.length === 1 && selected[0].type === 'token') { + announce(selected[0].text, 'assertive'); + } + } +} From dc5ec9f6ddc0b1cb284a77fbc690df4c23d5b564 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 13:11:33 -0700 Subject: [PATCH 03/18] Add support for placeholder tokens in PromptField --- packages/@react-spectrum/ai/exports/index.ts | 1 + .../@react-spectrum/ai/src/PromptField.tsx | 336 ++++++++++++++---- .../ai/stories/Chat.stories.tsx | 2 +- .../ai/stories/PromptField.stories.tsx | 213 +++++++---- 4 files changed, 418 insertions(+), 134 deletions(-) diff --git a/packages/@react-spectrum/ai/exports/index.ts b/packages/@react-spectrum/ai/exports/index.ts index 873d5def2d5..7fd5afd63de 100644 --- a/packages/@react-spectrum/ai/exports/index.ts +++ b/packages/@react-spectrum/ai/exports/index.ts @@ -47,6 +47,7 @@ export type { PromptFieldAttachmentListProps, PromptTokenFieldPopoverProps, PromptFieldToolbarProps, + PromptFieldTokenValue, InsertMenuItemProps, PromptFieldVoiceButtonProps, InsertTokenMenuItemProps, diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 3c1704c2832..5a7a7bd9730 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -14,22 +14,22 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import Attach from '@react-spectrum/s2/icons/Attach'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; +import {Button} from '@react-spectrum/s2/Button'; +import {Cell} from './loader/data'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { - baseColor, color, css, iconStyle, style, StyleString } from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Button} from '@react-spectrum/s2/Button'; -import {Cell} from './loader/data'; -import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { createContext, createRef, forwardRef, use, + useCallback, useContext, useDeferredValue, useEffect, @@ -38,9 +38,10 @@ import { useState } from 'react'; import {FocusableRef} from '@react-types/shared'; +import {getInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import {IconContext} from '@react-spectrum/s2'; -import {Image, Text} from '@react-spectrum/s2/Card'; // @ts-ignore +import {Image, Text} from '@react-spectrum/s2/Card'; import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {Link} from '@react-spectrum/s2/Link'; @@ -73,7 +74,6 @@ import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusableRef} from './useDOMRef'; import {useFocusWithin} from 'react-aria/useFocusWithin'; -import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -87,9 +87,9 @@ export interface PromptFieldAttachment { export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; - value?: TokenFieldValue; - defaultValue?: TokenFieldValue; - onChange?: (value: TokenFieldValue) => void; + value?: PromptFieldValue; + defaultValue?: PromptFieldValue; + onChange?: (value: PromptFieldValue) => void; attachments?: PromptFieldAttachment[]; defaultAttachments?: PromptFieldAttachment[]; onAttachmentsChange?: (attachments: PromptFieldAttachment[]) => void; @@ -107,8 +107,8 @@ interface PromptFieldState { attachments: PromptFieldAttachment[]; setAttachments: React.Dispatch>; acceptedAttachmentTypes?: string[]; - prompt: TokenFieldValue; - setPrompt: React.Dispatch>; + prompt: PromptFieldValue; + setPrompt: React.Dispatch>; inputRef: React.RefObject; onSubmit?: () => void; onStop?: () => void; @@ -146,10 +146,75 @@ function tokenizeURLs(text: string): TokenFieldSegment[] { return segments; } -export class PromptFieldValue extends TokenFieldValue { +interface UrlTokenValue { + type: 'url'; + url: string; +} + +interface PlaceholderTokenValue { + type: 'placeholder'; + placeholderType: 'token'; + /** Anchor character to insert when the user starts typing (e.g. '@'). */ + anchor: string; + /** Expected value type to filter completions by. */ + valueType: string | null; +} + +interface PlaceholderTextTokenValue { + type: 'placeholder'; + placeholderType: 'text'; +} + +interface AnchorTokenValue { + type: 'anchor'; + valueType: string; +} + +interface CustomTokenValue { + type: 'custom'; + [key: string]: any; +} + +export type PromptFieldTokenValue = + | UrlTokenValue + | PlaceholderTokenValue + | PlaceholderTextTokenValue + | AnchorTokenValue + | CustomTokenValue; + +export class PromptFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { return tokenizeURLs(text); } + + replaceRangeWithSegments( + start: Position, + end: Position, + segments: TokenFieldSegment[], + coalesce = true + ): this { + let slice = this.slice(start, end).segments; + let token = slice[0]; + if ( + slice.length === 1 && + token.type === 'token' && + token.value?.type === 'placeholder' && + token.value.placeholderType === 'token' && + segments.length === 1 && + segments[0].type === 'text' && + !segments[0].text.startsWith(token.value.anchor) + ) { + segments = [ + { + type: 'token', + text: token.value.anchor, + value: {type: 'anchor', valueType: token.value.valueType} + }, + ...segments + ]; + } + return super.replaceRangeWithSegments(start, end, segments, coalesce); + } } const PromptFieldContext = createContext({ @@ -335,9 +400,10 @@ export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) export interface PromptTokenFieldProps { completionTrigger?: RegExp; renderCompletions?: ( - filterValue: string + filterValue: string, + valueType: string | null ) => React.ReactNode[] | null | Promise; - children?: (segment: TokenSegment) => React.ReactElement; + children?: (segment: TokenSegment) => React.ReactElement; pixelLoader?: Cell[] | Cell[][]; placeholder?: string; onKeyDown?: (e: React.KeyboardEvent) => void; @@ -356,7 +422,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { menuWidth, onKeyDown: onKeyDownProp } = props; - let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp}); let { prompt, setPrompt, @@ -371,24 +436,80 @@ export function PromptTokenField(props: PromptTokenFieldProps) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let [isFocused, setFocused] = useState(false); - let [filterAnchor, filterValue] = useMemo(() => { + let [filterAnchor, filterValue, filterType] = useMemo(() => { + // If on a placeholder token, show suggestions. + let segment = prompt.segments[prompt.selectedRange.anchor.index]; + if ( + segment?.type === 'token' && + segment.value?.type === 'placeholder' && + segment.value.placeholderType === 'token' + ) { + return [prompt.selectedRange.anchor, '', segment.value.valueType ?? null]; + } + if (completionTrigger) { + // Find a preceding anchor token. This tells us what kind of object to filter for. + let anchorTokenIndex = -1; + let filterType: string | null = null; + for ( + let index = Math.min(prompt.selectedRange.anchor.index, prompt.segments.length - 1); + index >= 0; + index-- + ) { + let segment = prompt.segments[index]; + if (segment.type === 'token' && segment.value?.type === 'anchor') { + anchorTokenIndex = index; + filterType = segment.value?.valueType; + break; + } + } + let filterAnchor = prompt.findText( prompt.caretPosition, TokenFieldValue.Direction.Backward, completionTrigger ); + + // If anchor token is after text anchor, use it. + if (anchorTokenIndex >= 0 && (!filterAnchor || anchorTokenIndex > filterAnchor.index)) { + filterAnchor = {index: anchorTokenIndex, offset: 0}; + } + + // Filter text is the text between the anchor and the caret position. if (filterAnchor != null) { let filterValue = prompt.slice(filterAnchor, prompt.caretPosition).toString(); - return [filterAnchor, filterValue]; + return [filterAnchor, filterValue, filterType]; } } - return [null, null]; + return [null, null, null]; }, [completionTrigger, prompt]); let items = useMemo(() => { - return filterValue != null ? renderCompletions?.(filterValue) : null; - }, [filterValue, renderCompletions]); + return filterValue != null ? renderCompletions?.(filterValue, filterType) : null; + }, [filterValue, filterType, renderCompletions]); + + let onKeyDown = (e: React.KeyboardEvent) => { + onKeyDownProp?.(e); + if (e.key === 'Tab') { + let index = prompt.caretPosition.index; + let dir = e.shiftKey ? -1 : 1; + for (let i = index + dir; i >= 0 && i < prompt.segments.length; i += dir) { + let segment = prompt.segments[i]; + if (segment.type === 'token' && segment.value?.type === 'placeholder') { + e.preventDefault(); + setPrompt(value => + value.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: i, offset: 0}, + {index: i, offset: segment.text.length} + ) + ) + ); + break; + } + } + } + }; return (
{ if (e.isTrusted) { setFocused(true); + + // If shift tabbing into the prompt field, select the last placeholder if any. + if ( + e.relatedTarget && + getInteractionModality() === 'keyboard' && + e.currentTarget.compareDocumentPosition(e.relatedTarget) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + let lastPlaceholder = prompt.segments.findLastIndex( + s => s.type === 'token' && s.value?.type === 'placeholder' + ); + if (lastPlaceholder >= 0) { + setPrompt(value => + value.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: lastPlaceholder, offset: 0}, + {index: lastPlaceholder, offset: 1} + ) + ) + ); + } + } } }} onBlur={e => { @@ -476,15 +620,14 @@ export function PromptTokenField(props: PromptTokenFieldProps) { : undefined }> + className={ css('&:empty::before { content: attr(data-placeholder); }') + style({ font: 'body', color: { - default: baseColor('neutral'), + default: 'neutral', ':empty': { default: 'gray-600', forcedColors: 'GrayText' @@ -493,9 +636,22 @@ export function PromptTokenField(props: PromptTokenFieldProps) { width: 'full', outlineStyle: 'none', cursor: 'text' - })(renderProps) + }) }> - {children || (segment => {segment.text})} + {useCallback( + (token: TokenSegment) => { + if (token.value?.type === 'anchor') { + return {token.text}; + } else { + return children ? ( + children(token) + ) : ( + {token.text} + ); + } + }, + [children] + )} 0 && filterAnchor.offset === 0) { + filterAnchor = { + index: filterAnchor.index - 1, + offset: prompt.segments[filterAnchor.index - 1].text.length + }; + } + // Reposition the popover when the anchor changes. + key = `${filterAnchor.index}:${filterAnchor.offset}`; + } + return ( { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> @@ -550,6 +720,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { } export interface PromptTokenProps extends Omit { + token: TokenSegment; children: React.ReactNode; } @@ -557,36 +728,44 @@ export function PromptToken(props: PromptTokenProps) { return ( + className={renderProps => + style({ + font: 'ui', + backgroundColor: { + default: 'transparent-overlay-1000/10', + isSelected: 'blue-800', + '::selection': 'transparent' + }, + color: { + default: 'body', + isSelected: 'white' + }, + outlineStyle: { + default: 'solid', + isPlaceholder: 'dashed' + }, + outlineWidth: 1, + outlineColor: { + default: 'transparent-overlay-1000/10', + isPlaceholder: 'transparent-overlay-1000/40' + }, + outlineOffset: -1, + borderRadius: 'pill', + boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`, + paddingX: 8, + paddingY: 4, + lineHeight: '[1em]', + cursor: 'default', + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + display: 'inline-flex', + alignItems: 'baseline', + gap: 4, + verticalAlign: 'baseline' + })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'}) + }> TokenFieldSegment[]) { +function useInsertPromptSegment(segments: TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); let pendingCaret = useRef(null); - return (item: any) => { + return () => { setPrompt(value => { + // Add a space only if not already followed by one, but move the cursor past the space in any case. + let space = value.findText(value.caretPosition, TokenFieldValue.Direction.Forward, ' '); + let hasFollowingSpace = + space && value.slice(value.caretPosition, space).segments.length === 0; + let insert: TokenFieldSegment[] = [...segments, {type: 'text', text: ' '}]; + let endPosition = value.caretPosition; + if (hasFollowingSpace && space) { + space.offset++; + endPosition = space; + } let newValue = value.replaceRangeWithSegments( anchor ?? value.caretPosition, - value.caretPosition, - buildSegments(item), + endPosition, + insert, false // Don't coalesce in undo/redo history. ); pendingCaret.current = newValue.caretPosition; @@ -851,19 +1040,19 @@ export interface InsertTokenMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + token: TokenSegment; +} export function InsertTokenMenuItem(props: InsertTokenMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'token', text: 'command' in item ? item.command : item.title, value: item}, - {type: 'text', text: ' '} - ]); + let insert = useInsertPromptSegment([props.token]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -882,18 +1071,19 @@ export interface InsertTextMenuItemProps extends Omit< | 'rel' | 'routerOptions' | 'target' -> {} + | 'value' +> { + text: string; +} export function InsertTextMenuItem(props: InsertTextMenuItemProps) { - let insert = useInsertPromptSegment(item => [ - {type: 'text', text: `${'command' in item ? item.command : item.title} `} - ]); + let insert = useInsertPromptSegment([{type: 'text', text: props.text}]); return ( { - insert(props.value); + insert(); props.onAction?.(); }} /> @@ -917,12 +1107,12 @@ export interface CommandMenuItemProps extends Omit< // since they dont end up inserting a token or text, we need to clear the partial text that the user used // to filter the menu export function CommandMenuItem(props: CommandMenuItemProps) { - let insert = useInsertPromptSegment(() => []); + let insert = useInsertPromptSegment([]); return ( { - insert(undefined); + insert(); props.onAction?.(); }} /> diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 715184cb6f9..344de025e87 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -215,7 +215,7 @@ export function VirtualizedStreamingChat() { let nextId = useRef(initialResponses.length); let [isGenerating, setGenerating] = useState(false); let timeouts = useRef([]); - let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); + let [promptValue, setPromptValue] = useState(new PromptFieldValue([])); let followUpMessage = useRef(null); function handleSend(prompt: TokenFieldValue) { diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index e76902550b4..732ef21eae4 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -21,6 +21,7 @@ import { PromptFieldAttachment, PromptFieldAttachmentList, PromptFieldSubmitButton, + PromptFieldTokenValue, PromptFieldToolbar, PromptFieldValue, PromptFieldVoiceButton, @@ -30,6 +31,7 @@ import { import {Attachment} from '../src/AttachmentList'; import Brand from '@react-spectrum/s2/icons/Brand'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; +import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import { Collection, Header, @@ -53,6 +55,7 @@ import Plugin from '@react-spectrum/s2/icons/Plugin'; import Prompt from '@react-spectrum/s2/icons/Prompt'; import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; import {TokenFieldValue} from 'react-aria-components'; +import {TokenSegment} from 'react-stately'; import {useRef, useState} from 'react'; import UserGroup from '@react-spectrum/s2/icons/UserGroup'; @@ -126,61 +129,78 @@ type Story = StoryObj; const slashCommands = [ { command: '/audience-explainer', - type: 'skill', + kind: 'skill', description: 'Explain an AEP audience in english' }, - {command: '/btw', type: 'command', description: 'Ask a side question'}, - {command: '/clear', type: 'command', description: 'Clear the context'}, - {command: '/compact', type: 'command', description: 'Summarize conversation history'}, - {command: '/dataset-usage', type: 'skill', description: 'Explain how to use a dataset'}, - {command: '/feedback', type: 'command', description: 'Submit feedback'}, - {command: '/plan', type: 'command', description: 'Create a plan before executing'}, - {command: '/visual-artifact', type: 'skill', description: 'Generate a chart or graph'} + {command: '/btw', kind: 'command', description: 'Ask a side question'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/dataset-usage', kind: 'skill', description: 'Explain how to use a dataset'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'}, + {command: '/plan', kind: 'command', description: 'Create a plan before executing'}, + {command: '/visual-artifact', kind: 'skill', description: 'Generate a chart or graph'} ]; const icons = { - command: , - skill: , - audience: , - campaign: , - journey: , - url: + command: , + skill: , + audience: , + campaign: , + journey: , + url: } as const; +function getIcon(token: TokenSegment) { + switch (token.value?.type) { + case 'placeholder': + return token.value.placeholderType === 'token' && token.value.valueType + ? icons[token.value.valueType] + : null; + case 'url': + return icons.url; + case 'custom': + return icons[token.value.kind]; + } +} + const objects = [ { section: 'Audiences', + type: 'audience', items: [ - {type: 'audience', title: 'New Customers'}, - {type: 'audience', title: 'Returning Customers'}, - {type: 'audience', title: 'Loyal Customers'}, - {type: 'audience', title: 'High-Value Customers'}, - {type: 'audience', title: 'Low-Value Customers'} + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'}, + {kind: 'audience', title: 'Loyal Customers'}, + {kind: 'audience', title: 'High-Value Customers'}, + {kind: 'audience', title: 'Low-Value Customers'} ] }, { section: 'Campaigns', + type: 'campaign', items: [ - {type: 'campaign', title: 'Spring Launch 2026'}, - {type: 'campaign', title: 'Holiday Cheer'}, - {type: 'campaign', title: 'Back to School'}, - {type: 'campaign', title: 'Summer Adventure'}, - {type: 'campaign', title: 'Tech Trends Expo'} + {kind: 'campaign', title: 'Spring Launch 2026'}, + {kind: 'campaign', title: 'Holiday Cheer'}, + {kind: 'campaign', title: 'Back to School'}, + {kind: 'campaign', title: 'Summer Adventure'}, + {kind: 'campaign', title: 'Tech Trends Expo'} ] }, { section: 'Journeys', + type: 'journey', items: [ - {type: 'journey', title: 'Welcome Flow'}, - {type: 'journey', title: 'Abandoned Cart Recovery'}, - {type: 'journey', title: 'Post-Purchase Follow-up'}, - {type: 'journey', title: 'Re-engagement Campaign'}, - {type: 'journey', title: 'Birthday Surprise Journey'} + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'}, + {kind: 'journey', title: 'Post-Purchase Follow-up'}, + {kind: 'journey', title: 'Re-engagement Campaign'}, + {kind: 'journey', title: 'Birthday Surprise Journey'} ] } ]; interface CompletionCallbacks { + valueType?: string | null; onClear?: () => void; onCompact?: () => void; } @@ -204,26 +224,33 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ) : item.command === '/feedback' || item.command === '/btw' ? ( // coworker doesn't seem to have any text insertion commands anymore, so I added these for testing - + {item.command} {item.description} ) : ( - - {item.type === 'skill' ? : } + + {item.kind === 'skill' ? : } {item.command} {item.description} ) ); - } else if (filterValue.startsWith('@')) { + } else if (filterValue.startsWith('@') || callbacks?.valueType) { return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) .map(section => { let matchingItems = section.items .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) .map(item => ( - + {item.title} )); @@ -258,7 +285,11 @@ function atEnd(v: PromptFieldValue) { let prompt1 = new PromptFieldValue([ {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', kind: 'audience', title: 'New Customers'} + }, {type: 'text', text: ' and suggest targeting strategies'} ]); @@ -267,23 +298,42 @@ let prompt2 = new PromptFieldValue([ { type: 'token', text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} + value: {type: 'custom', kind: 'campaign', title: 'Spring Launch 2026'} } ]); let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, - {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} + { + type: 'token', + text: 'Welcome Flow', + value: {type: 'custom', kind: 'journey', title: 'Welcome Flow'} + } ]); +let prompt4 = new PromptFieldValue( + [ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed significantly in the past '}, + {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + ] + // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} +); + let prompts = [ prompt1.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt1))), prompt2.withSelectedRange(new PromptFieldValue.SelectedRange(atEnd(prompt2))), prompt3Base.replaceRange( atEnd(prompt3Base), atEnd(prompt3Base), - ' journey performance from test.com /' - ) + ' journey performance from test.com' + ), + prompt4 ]; function EverythingRender(args) { @@ -374,7 +424,37 @@ function EverythingRender(args) { setValue(prompt); promptFieldRef.current?.focus(); }}> - {prompt.toString()} + {prompt.segments.map((s, i) => + s.type === 'token' ? ( + + {getIcon(s) && {getIcon(s)}} + {s.text} + + ) : ( + s.text + ) + )} ))} @@ -435,23 +515,24 @@ function EverythingRender(args) { - renderCompletions(filterValue, { + renderCompletions={(filterValue, valueType) => { + return renderCompletions(filterValue, { + valueType, onClear: () => { setValue(new PromptFieldValue([])); setAttachments([]); }, onCompact: action('onCompact') - }) - } + }); + }} + onKeyDown={onKeyDown} pixelLoader={data[args.pixelLoader]} placeholder={placeholder} - menuWidth={menuWidth} - onKeyDown={onKeyDown}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + menuWidth={menuWidth}> + {token => ( + + {getIcon(token)} + {token.text} )} @@ -463,7 +544,7 @@ function EverythingRender(args) { Commands - item.type === 'command')}> + item.kind === 'command')}> {item => item.command === '/clear' ? ( {item.description} ) : item.command === '/feedback' || item.command === '/btw' ? ( - + {item.command} {item.description} ) : ( - + {item.command} {item.description} @@ -499,9 +582,11 @@ function EverythingRender(args) { Skills - item.type === 'skill')}> + item.kind === 'skill')}> {item => ( - + {item.command} {item.description} @@ -521,7 +606,15 @@ function EverythingRender(args) { {item => ( - {item.title} + + {item.title} + )} @@ -568,10 +661,10 @@ export const AsyncCompletions = () => ( await new Promise(resolve => setTimeout(resolve, 500)); return renderCompletions(filterValue); }}> - {segment => ( - - {icons[segment.value?.type]} - {segment.text} + {token => ( + + {getIcon(token)} + {token.text} )} From 2865b4ccab3c70321364962e36869b6899db484f Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 13:21:35 -0700 Subject: [PATCH 04/18] fix lint --- packages/react-aria/src/tokenfield/useTokenField.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 4398a1566d1..3f15aadd855 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -20,11 +20,10 @@ import { useMemo, useRef } from 'react'; -import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; import {isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; -import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import { Position, SelectedRange, From 7e68cdf9cedc921d29a0d004aeccec149902d795 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 13:56:48 -0700 Subject: [PATCH 05/18] Fix showing autocomplete when clicking placeholder token --- .../@react-spectrum/ai/src/PromptField.tsx | 5 +++-- .../react-aria/src/tokenfield/useTokenField.ts | 2 +- .../src/tokenfield/TokenFieldValue.ts | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 5a7a7bd9730..fc16d7f0c12 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -438,13 +438,14 @@ export function PromptTokenField(props: PromptTokenFieldProps) { let [filterAnchor, filterValue, filterType] = useMemo(() => { // If on a placeholder token, show suggestions. - let segment = prompt.segments[prompt.selectedRange.anchor.index]; + let slice = prompt.slice(prompt.selectedRange.start, prompt.selectedRange.end); + let segment = slice.segments.length === 1 ? slice.segments[0] : null; if ( segment?.type === 'token' && segment.value?.type === 'placeholder' && segment.value.placeholderType === 'token' ) { - return [prompt.selectedRange.anchor, '', segment.value.valueType ?? null]; + return [prompt.selectedRange.start, '', segment.value.valueType ?? null]; } if (completionTrigger) { diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 3f15aadd855..2062ecb9fad 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -870,7 +870,7 @@ function announceToken(value: TokenFieldValue, range = value.selectedRange) { } } else { // Announce token if it is the only thing selected. - let selected = value.slice(range.anchor, range.current).segments; + let selected = value.slice(range.start, range.end).segments; if (selected.length === 1 && selected[0].type === 'token') { announce(selected[0].text, 'assertive'); } diff --git a/packages/react-stately/src/tokenfield/TokenFieldValue.ts b/packages/react-stately/src/tokenfield/TokenFieldValue.ts index 9c1361e6801..8f63d96d702 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -52,6 +52,16 @@ export class SelectedRange { return this.anchor.index === this.current.index && this.anchor.offset === this.current.offset; } + /** The side of the selection closest to the start of the value. */ + get start() { + return compare(this.anchor, this.current) < 0 ? this.anchor : this.current; + } + + /** The side of the selection closest to the end of the value. */ + get end() { + return compare(this.anchor, this.current) < 0 ? this.current : this.anchor; + } + /** Returns whether this selection is equal to another. */ isEqual(other: SelectedRange) { if (this === other) { @@ -66,6 +76,14 @@ export class SelectedRange { } } +function compare(a: Position, b: Position) { + if (a.index === b.index) { + return a.offset - b.offset; + } + + return a.index - b.index; +} + enum Direction { Forward = 1, Backward = -1 From 5f136650cbc51133064d9fba383e3d33bc3a95cb Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 14:03:55 -0700 Subject: [PATCH 06/18] Only expand range around token when not collapsed --- packages/react-aria/src/tokenfield/useTokenField.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 2062ecb9fad..02c2be84999 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -634,13 +634,18 @@ export function getSelectedRange(container: Element) { return null; } let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset, false); - let current = getPosition(container, selection.focusNode, selection.focusOffset, true); + let current = getPosition( + container, + selection.focusNode, + selection.focusOffset, + !selection.isCollapsed + ); return new TokenFieldValue.SelectedRange(anchor, current); } function rangeToPositions(container: Element, range: Range | StaticRange): [Position, Position] { let start = getPosition(container, range.startContainer, range.startOffset, false); - let end = getPosition(container, range.endContainer, range.endOffset, true); + let end = getPosition(container, range.endContainer, range.endOffset, !range.collapsed); return [start, end]; } From 39208ed04686f938db697206b745abedb011f50c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Aug 2026 14:46:55 -0700 Subject: [PATCH 07/18] Collapse selection on blur but don't lose the position fixes inserting objects via plus menu --- packages/react-aria/src/tokenfield/useTokenField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 02c2be84999..8d0db5ce8c4 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -414,7 +414,7 @@ export function useTokenField( if (ref.current && selection && selection.containsNode(ref.current, true)) { selection.removeAllRanges(); state.setValue(value => - value.withSelectedRange(new TokenFieldValue.SelectedRange({index: 0, offset: 0})) + value.withSelectedRange(new TokenFieldValue.SelectedRange(value.caretPosition)) ); } }); From ab776afb40cc77e215b7757beb3a3fa06356bb60 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 18:03:13 -0700 Subject: [PATCH 08/18] fix firefox and safari styling issues --- .../@react-spectrum/ai/src/PromptField.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index fc16d7f0c12..f1a8be636d1 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -735,7 +735,8 @@ export function PromptToken(props: PromptTokenProps) { backgroundColor: { default: 'transparent-overlay-1000/10', isSelected: 'blue-800', - '::selection': 'transparent' + // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead + '::selection': '[#ffffff01]' }, color: { default: 'body', @@ -754,23 +755,24 @@ export function PromptToken(props: PromptTokenProps) { borderRadius: 'pill', boxShadow: `[inset 0 24px 32px 0 ${color('transparent-white-50')}, 0 8px 32px 0 ${color('transparent-black-50')}]`, paddingX: 8, - paddingY: 4, + // not using inline-flex here due to a text selection bug in WebKit. + paddingY: space(3), lineHeight: '[1em]', cursor: 'default', '--iconPrimary': { type: 'fill', value: 'currentColor' - }, - display: 'inline-flex', - alignItems: 'baseline', - gap: 4, - verticalAlign: 'baseline' + } })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'}) }> {icon} + styles: style({ + size: 14, + display: 'inline-block', + verticalAlign: '[-0.18em]', + marginEnd: 4 + }) }}> {props.children} From 8af9f021f2e2eb747139b0dd20538211ad8181cc Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 18:03:36 -0700 Subject: [PATCH 09/18] useKeyboard --- .../@react-spectrum/ai/src/PromptField.tsx | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index f1a8be636d1..37d4d7fab24 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -489,29 +489,24 @@ export function PromptTokenField(props: PromptTokenFieldProps) { return filterValue != null ? renderCompletions?.(filterValue, filterType) : null; }, [filterValue, filterType, renderCompletions]); - let onKeyDown = (e: React.KeyboardEvent) => { - onKeyDownProp?.(e); - if (e.key === 'Tab') { - let index = prompt.caretPosition.index; - let dir = e.shiftKey ? -1 : 1; - for (let i = index + dir; i >= 0 && i < prompt.segments.length; i += dir) { - let segment = prompt.segments[i]; - if (segment.type === 'token' && segment.value?.type === 'placeholder') { - e.preventDefault(); - setPrompt(value => - value.withSelectedRange( - new TokenFieldValue.SelectedRange( - {index: i, offset: 0}, - {index: i, offset: segment.text.length} - ) - ) - ); - break; - } - } + let tab = (dir: number) => { + // TODO: should we support tabbing to all tokens or only placeholders? + let nextPrompt = selectNextPlaceholder(prompt, dir); + if (nextPrompt) { + setPrompt(nextPrompt); + return true; } + return false; }; + let {keyboardProps} = useKeyboard({ + onKeyDown: onKeyDownProp, + shortcuts: { + Tab: () => tab(1), + 'Shift+Tab': () => tab(-1) + } + }); + return (
{ if (e.isTrusted) { setFocused(true); From 35f65678aa92ba02518eed9bb4dff4828e64391b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 18:04:56 -0700 Subject: [PATCH 10/18] show menu on all tokens, not just placeholders --- .../@react-spectrum/ai/src/PromptField.tsx | 80 ++++++++++++------- .../ai/stories/PromptField.stories.tsx | 48 +++++++---- 2 files changed, 84 insertions(+), 44 deletions(-) diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 37d4d7fab24..730a4b6c0f4 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -17,13 +17,7 @@ import {Autocomplete} from 'react-aria-components/Autocomplete'; import {Button} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; -import { - color, - css, - iconStyle, - style, - StyleString -} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { createContext, createRef, @@ -40,8 +34,8 @@ import { import {FocusableRef} from '@react-types/shared'; import {getInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import {IconContext} from '@react-spectrum/s2'; -// @ts-ignore import {Image, Text} from '@react-spectrum/s2/Card'; +// @ts-ignore import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {Link} from '@react-spectrum/s2/Link'; @@ -52,6 +46,7 @@ import Plus from '@react-spectrum/s2/icons/Add'; import {Popover, PopoverProps} from '@react-spectrum/s2/Popover'; import { Position, + SelectedRange, TokenFieldSegment, TokenFieldValue, TokenSegment @@ -74,6 +69,7 @@ import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusableRef} from './useDOMRef'; import {useFocusWithin} from 'react-aria/useFocusWithin'; +import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -172,7 +168,12 @@ interface AnchorTokenValue { interface CustomTokenValue { type: 'custom'; - [key: string]: any; + /** Anchor character to insert when the user starts typing to replace the token (e.g. '@'). */ + anchor: string; + /** Type of the token value, used to filter replacement completions. */ + valueType: string; + /** Arbitrary token data. */ + data: any; } export type PromptFieldTokenValue = @@ -198,8 +199,8 @@ export class PromptFieldValue extends TokenFieldValue { if ( slice.length === 1 && token.type === 'token' && - token.value?.type === 'placeholder' && - token.value.placeholderType === 'token' && + ((token.value?.type === 'placeholder' && token.value.placeholderType === 'token') || + token.value?.type === 'custom') && segments.length === 1 && segments[0].type === 'text' && !segments[0].text.startsWith(token.value.anchor) @@ -442,10 +443,10 @@ export function PromptTokenField(props: PromptTokenFieldProps) { let segment = slice.segments.length === 1 ? slice.segments[0] : null; if ( segment?.type === 'token' && - segment.value?.type === 'placeholder' && - segment.value.placeholderType === 'token' + ((segment.value?.type === 'placeholder' && segment.value.placeholderType === 'token') || + segment.value?.type === 'custom') ) { - return [prompt.selectedRange.start, '', segment.value.valueType ?? null]; + return [prompt.selectedRange.start, segment.value.anchor, segment.value.valueType ?? null]; } if (completionTrigger) { @@ -661,6 +662,23 @@ export function PromptTokenField(props: PromptTokenFieldProps) { ); } +function selectNextPlaceholder(prompt: PromptFieldValue, dir: number): PromptFieldValue | null { + let index = prompt.caretPosition.index; + for (let i = index + dir; i >= 0 && i < prompt.segments.length; i += dir) { + let segment = prompt.segments[i]; + if (segment.type === 'token' && segment.value?.type === 'placeholder') { + return prompt.withSelectedRange( + new TokenFieldValue.SelectedRange( + {index: i, offset: 0}, + {index: i, offset: segment.text.length} + ) + ); + } + } + + return null; +} + export interface PromptTokenFieldPopoverProps extends Omit { filterAnchor?: Position | null; items?: React.ReactNode[] | null | Promise; @@ -708,7 +726,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { getTargetRect={target => { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> - + {menuItems} @@ -981,36 +999,38 @@ export function AttachFileMenuItem() { function useInsertPromptSegment(segments: TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); - let pendingCaret = useRef(null); + let pendingSelection = useRef(null); return () => { setPrompt(value => { // Add a space only if not already followed by one, but move the cursor past the space in any case. - let space = value.findText(value.caretPosition, TokenFieldValue.Direction.Forward, ' '); - let hasFollowingSpace = - space && value.slice(value.caretPosition, space).segments.length === 0; - let insert: TokenFieldSegment[] = [...segments, {type: 'text', text: ' '}]; - let endPosition = value.caretPosition; - if (hasFollowingSpace && space) { - space.offset++; - endPosition = space; + let insert: TokenFieldSegment[] = [...segments]; + let endPosition = value.selectedRange.end; + if (insert.length) { + let space = value.findText(endPosition, TokenFieldValue.Direction.Forward, ' '); + let hasFollowingSpace = space && value.slice(endPosition, space).segments.length === 0; + insert.push({type: 'text', text: ' '}); + if (hasFollowingSpace && space) { + space.offset++; + endPosition = space; + } } let newValue = value.replaceRangeWithSegments( - anchor ?? value.caretPosition, + anchor ?? value.selectedRange.start, endPosition, insert, false // Don't coalesce in undo/redo history. ); - pendingCaret.current = newValue.caretPosition; + newValue = selectNextPlaceholder(newValue, 1) || newValue; + pendingSelection.current = newValue.selectedRange; return newValue; }); if (anchor == null) { // Wait for popover animation, then restore cursor to after the inserted content. setTimeout(() => { - if (inputRef.current && pendingCaret.current) { - let position = pendingCaret.current; - let range = new TokenFieldValue.SelectedRange(position); - pendingCaret.current = null; + if (inputRef.current && pendingSelection.current) { + let range = pendingSelection.current; + pendingSelection.current = null; inputRef.current.focus(); // we need to update the position manually since TokenField's update caret logic only happens if the field is focused // but this insert can happen from the + menu aka the field isn't focused until this gets called which is too late diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 732ef21eae4..64032ea3a23 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -159,7 +159,7 @@ function getIcon(token: TokenSegment) { case 'url': return icons.url; case 'custom': - return icons[token.value.kind]; + return icons[token.value.valueType]; } } @@ -208,7 +208,11 @@ interface CompletionCallbacks { function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) { if (filterValue.startsWith('/')) { return slashCommands - .filter(item => item.command.includes(filterValue.slice(1))) + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) .map(item => item.command === '/clear' ? ( @@ -233,14 +237,18 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) + token={{ + type: 'token', + text: item.command, + value: {type: 'custom', anchor: '/', valueType: item.kind, data: item} + }}> {item.kind === 'skill' ? : } {item.command} {item.description} ) ); - } else if (filterValue.startsWith('@') || callbacks?.valueType) { + } else if (filterValue.startsWith('@')) { return objects .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) .map(section => { @@ -250,7 +258,11 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) + token={{ + type: 'token', + text: item.title, + value: {type: 'custom', anchor: '@', valueType: item.kind, data: item} + }}> {item.title} )); @@ -288,7 +300,7 @@ let prompt1 = new PromptFieldValue([ { type: 'token', text: 'New Customers', - value: {type: 'custom', kind: 'audience', title: 'New Customers'} + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} }, {type: 'text', text: ' and suggest targeting strategies'} ]); @@ -298,7 +310,7 @@ let prompt2 = new PromptFieldValue([ { type: 'token', text: 'Spring Launch 2026', - value: {type: 'custom', kind: 'campaign', title: 'Spring Launch 2026'} + value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {title: 'Spring Launch 2026'}} } ]); @@ -307,7 +319,7 @@ let prompt3Base = new PromptFieldValue([ { type: 'token', text: 'Welcome Flow', - value: {type: 'custom', kind: 'journey', title: 'Welcome Flow'} + value: {type: 'custom', anchor: '@', valueType: 'journey', data: {title: 'Welcome Flow'}} } ]); @@ -320,7 +332,7 @@ let prompt4 = new PromptFieldValue( value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} }, {type: 'text', text: ' that changed significantly in the past '}, - {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + {type: 'token', text: 'date', value: {type: 'placeholder', placeholderType: 'text'}} ] // {selectedRange: new TokenFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 1})} ); @@ -331,7 +343,7 @@ let prompts = [ prompt3Base.replaceRange( atEnd(prompt3Base), atEnd(prompt3Base), - ' journey performance from test.com' + ' journey performance from test.com ' ), prompt4 ]; @@ -435,7 +447,7 @@ function EverythingRender(args) { }, outlineWidth: 1, outlineColor: { - default: 'transparent-overlay-1000/10', + default: 'transparent-overlay-1000/20', isPlaceholder: 'transparent-overlay-1000/40' }, outlineOffset: -1, @@ -569,7 +581,11 @@ function EverythingRender(args) { ) : ( + token={{ + type: 'token', + text: item.command, + value: {type: 'custom', anchor: '/', valueType: item.kind, data: item} + }}> {item.command} {item.description} @@ -586,7 +602,11 @@ function EverythingRender(args) { {item => ( + token={{ + type: 'token', + text: item.command, + value: {type: 'custom', anchor: '/', valueType: item.kind, data: item} + }}> {item.command} {item.description} @@ -611,7 +631,7 @@ function EverythingRender(args) { token={{ type: 'token', text: item.title, - value: {type: 'custom', ...item} + value: {type: 'custom', anchor: '@', valueType: item.kind, data: item} }}> {item.title} From 41922f4b865d5987b6920c32b94b160eec2ce215 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 19:20:09 -0700 Subject: [PATCH 11/18] fix popover positioning when token is the first element --- .../src/tokenfield/useTokenField.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 8d0db5ce8c4..fa9e8b0296b 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -397,7 +397,7 @@ export function useTokenField( announceToken(value, range); - // Update the caret position in the value. Also update the ref so the layout + // Update the selected range in the value. Also update the ref so the layout // effect does not re-apply this selection back to the DOM, which would clobber // the browser's native selection direction (e.g. directionless double-click). selectedRange.current = range; @@ -717,7 +717,28 @@ export function setTokenFieldSelection( } export function tokenFieldPositionToDOMRange(root: Element, pos: Position): Range { - return createDOMRange(root, pos, pos); + // Unlike createDOMRange (used for caret/selection placement), this range is only + // measured via getBoundingClientRect to position things like an autocomplete popover. + // Place the endpoints inside the token's zero width space wrappers so the range has a + // valid rect at the token, rather than a collapsed root-level position. + let range = document.createRange(); + let [startContainer, startOffset] = getDOMRectPosition(root, pos); + range.setStart(startContainer, startOffset); + range.setEnd(startContainer, startOffset); + return range; +} + +function getDOMRectPosition(root: Element, pos: Position): [Node, number] { + let child = root.childNodes[pos.index]; + if (child && child.nodeType === Node.ELEMENT_NODE) { + // Place the position inside the zero width space wrappers around the token. + if (pos.offset > 0) { + return [child.lastChild!, 1]; + } else { + return [child.firstChild!, 0]; + } + } + return getDOMPosition(root, pos); } function createDOMRange(root: Element, start: Position, end: Position): Range { @@ -735,6 +756,7 @@ function getDOMPosition(root: Element, pos: Position): [Node, number] { return [root, Math.min(root.childNodes.length, pos.index)]; } else if (child.nodeType === Node.ELEMENT_NODE) { // Place the cursor outside the token wrapper element. + // This is necessary for composition events. if (pos.offset > 0) { return [root, pos.index + 1]; } else { From 68488adc876444086dd4534fe7913ad3b84cc947 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 19:43:18 -0700 Subject: [PATCH 12/18] Add PromptField browser tests --- .../ai/test/PromptField.browser.test.tsx | 346 ++++++++++++++++ .../ai/test/utils/promptFieldBrowserUtils.tsx | 376 ++++++++++++++++++ vitest.browser.config.ts | 7 +- 3 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 packages/@react-spectrum/ai/test/PromptField.browser.test.tsx create mode 100644 packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx diff --git a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx new file mode 100644 index 00000000000..21014cb5c91 --- /dev/null +++ b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx @@ -0,0 +1,346 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {describe, expect, it} from 'vitest'; +import { + focusField, + imageAttachment, + PromptFieldValue, + renderPromptField, + renderUncontrolledPromptField, + tokenTexts, + waitForFieldText, + waitForTokens +} from './utils/promptFieldBrowserUtils'; +import {page, userEvent} from 'vitest/browser'; +import React from 'react'; + +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +// A prompt containing both a fillable object placeholder (Journey) and a free-text placeholder (Date). +function placeholderPrompt(): PromptFieldValue { + return new PromptFieldValue([ + {type: 'text', text: 'Detect audiences in '}, + { + type: 'token', + text: 'Journey', + value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} + }, + {type: 'text', text: ' that changed in the past '}, + {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + ]); +} + +let menuItem = (name: string | RegExp) => page.getByRole('menuitem', {name}); +let menuItemCount = (name: string | RegExp) => menuItem(name).elements().length; + +describeOrSkip('PromptField', () => { + describe('placeholder text', () => { + it('shows the placeholder when empty and hides it after typing', async () => { + let {textbox, getValue} = await renderPromptField({placeholder: 'Ask me anything'}); + expect(textbox.element()).toHaveAttribute('data-placeholder', 'Ask me anything'); + + await focusField(textbox); + await userEvent.keyboard('hi'); + await waitForFieldText(getValue, 'hi'); + }); + }); + + describe('autocomplete trigger: @', () => { + it('opens object completions and inserts a token', async () => { + let {textbox, getValue} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('@'); + + // All object sections are shown for a bare @ trigger. + await expect.element(menuItem('New Customers')).toBeInTheDocument(); + await expect.element(menuItem('Spring Launch 2026')).toBeInTheDocument(); + await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); + + await userEvent.click(menuItem('New Customers')); + // Token replaces the typed filter and a trailing space is added. + await waitForTokens(getValue, ['New Customers']); + await waitForFieldText(getValue, 'New Customers '); + }); + + it('filters completions as the user types', async () => { + let {textbox} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('@New'); + + await expect.element(menuItem('New Customers')).toBeInTheDocument(); + await expect.poll(() => menuItemCount('Welcome Flow')).toBe(0); + }); + + it('does not trigger when @ is not preceded by whitespace or start', async () => { + let {textbox} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('hello@'); + + // No completion popover should open. + await expect.poll(() => page.getByRole('menu').elements().length).toBe(0); + }); + }); + + describe('autocomplete trigger: /', () => { + it('inserts a token via InsertTokenMenuItem', async () => { + let {textbox, getValue} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('/'); + + await expect.element(menuItem('/audience-explainer')).toBeInTheDocument(); + await expect.element(menuItem('/clear')).toBeInTheDocument(); + await expect.element(menuItem('/compact')).toBeInTheDocument(); + await expect.element(menuItem('/feedback')).toBeInTheDocument(); + + await userEvent.click(menuItem('/audience-explainer')); + await waitForTokens(getValue, ['/audience-explainer']); + }); + + it('inserts plain text via InsertTextMenuItem', async () => { + let {textbox, getValue} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('/feedback'); + + await userEvent.click(menuItem('/feedback')); + await waitForFieldText(getValue, '/feedback '); + expect(tokenTexts(getValue())).toEqual([]); + }); + + it('runs a callback and clears the filter via CommandMenuItem', async () => { + let {textbox, getValue, onCompact} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('/compact'); + + await userEvent.click(menuItem('/compact')); + await expect.poll(() => onCompact).toHaveBeenCalledTimes(1); + // Nothing inserted, and the filter text is cleared. + expect(tokenTexts(getValue())).toEqual([]); + expect(getValue().toString()).not.toContain('/compact'); + }); + + it('runs a callback via a plain MenuItem', async () => { + let {textbox, onClear} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('/clear'); + + await userEvent.click(menuItem('/clear')); + await expect.poll(() => onClear).toHaveBeenCalledTimes(1); + }); + }); + + describe('URL tokenization', () => { + it('auto-tokenizes a URL as it is typed', async () => { + let {textbox, getValue} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('visit test.com now'); + + await expect + .poll(() => getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')) + .toBe(true); + let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); + expect(urlToken?.text).toBe('test.com'); + }); + }); + + describe('insert (+) menu', () => { + it('inserts an object token from the Reference submenu', async () => { + let {textbox, getValue} = await renderPromptField(); + // Type first so the field has a live caret to insert at. + await focusField(textbox); + await userEvent.keyboard('Use '); + await userEvent.click(page.getByRole('button', {name: 'Add'})); + + await expect.element(menuItem('Attach a file')).toBeInTheDocument(); + let referenceItem = menuItem('Reference an object'); + await expect.element(referenceItem).toBeInTheDocument(); + + // Open the submenu. + await userEvent.hover(referenceItem); + await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); + await userEvent.click(menuItem('Welcome Flow')); + + await waitForTokens(getValue, ['Welcome Flow']); + await waitForFieldText(getValue, 'Use Welcome Flow '); + }); + + it('does not insert a double space when the caret is already followed by one', async () => { + let {textbox, getValue} = await renderPromptField(); + await focusField(textbox); + await userEvent.keyboard('x y'); + // Move the caret between 'x' and the space. + await userEvent.keyboard('{ArrowLeft}{ArrowLeft}'); + + await userEvent.click(page.getByRole('button', {name: 'Add'})); + await userEvent.hover(menuItem('Reference an object')); + await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); + await userEvent.click(menuItem('Welcome Flow')); + + // Reuses the existing trailing space rather than adding a second one. + await waitForFieldText(getValue, 'xWelcome Flow y'); + }); + }); + + describe('replacing an existing token', () => { + it('opens same-type completions when a custom token is selected', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {kind: 'audience', title: 'New Customers'}} + } + ]); + let {textbox, getValue} = await renderPromptField({initialValue}); + await focusField(textbox); + + // Selecting the existing token opens completions filtered to the same type (audiences). + await userEvent.click(page.getByText('New Customers')); + await expect.element(menuItem('Returning Customers')).toBeInTheDocument(); + await expect.poll(() => menuItemCount('Welcome Flow')).toBe(0); + await expect.poll(() => menuItemCount('Spring Launch 2026')).toBe(0); + + // Choosing a different audience replaces the selected token. + await userEvent.click(menuItem('Returning Customers')); + await waitForTokens(getValue, ['Returning Customers']); + }); + }); + + describe('placeholders', () => { + it('moves between placeholders with Tab and Shift+Tab', async () => { + let {textbox, getValue} = await renderPromptField({initialValue: placeholderPrompt()}); + await focusField(textbox); + await userEvent.keyboard('{Home}'); + + // Tab selects the first placeholder (Journey, index 1). + await userEvent.keyboard('{Tab}'); + await expect.poll(() => getValue().selectedRange.start).toEqual({index: 1, offset: 0}); + await expect + .poll(() => getValue().selectedRange.end) + .toEqual({index: 1, offset: 'Journey'.length}); + + // Tab again selects the next placeholder (Date, index 3). + await userEvent.keyboard('{Tab}'); + await expect.poll(() => getValue().selectedRange.start).toEqual({index: 3, offset: 0}); + await expect + .poll(() => getValue().selectedRange.end) + .toEqual({index: 3, offset: 'Date'.length}); + + // Shift+Tab goes back to the Journey placeholder. + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + await expect.poll(() => getValue().selectedRange.start).toEqual({index: 1, offset: 0}); + }); + + it('re-opens completions filtered to the placeholder value type when typed over', async () => { + let {textbox} = await renderPromptField({initialValue: placeholderPrompt()}); + await focusField(textbox); + await userEvent.keyboard('{Home}'); + await userEvent.keyboard('{Tab}'); // select the Journey placeholder + await userEvent.keyboard('W'); + + // Completions re-open, filtered to journeys only. + await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); + await expect.poll(() => menuItemCount('New Customers')).toBe(0); + }); + + it('selects the last placeholder when Shift+Tab-ing into the field', async () => { + let {textbox, getValue} = await renderPromptField({initialValue: placeholderPrompt()}); + await focusField(textbox); + // Caret past the last placeholder so Tab leaves the field instead of jumping placeholders. + await userEvent.keyboard('{End}'); + await userEvent.keyboard('{Tab}'); + // Re-enter from a following element; the last placeholder (Date, index 3) is auto-selected. + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + + await expect.poll(() => getValue().selectedRange.start).toEqual({index: 3, offset: 0}); + await expect.poll(() => getValue().selectedRange.end).toEqual({index: 3, offset: 1}); + }); + }); + + describe('submit / generate state', () => { + it('disables submit when empty and enables it with content', async () => { + let {textbox, getValue, onSubmit} = await renderPromptField(); + let submit = page.getByRole('button', {name: 'Send'}); + await expect.element(submit).toBeDisabled(); + + await focusField(textbox); + await userEvent.keyboard('hello'); + await waitForFieldText(getValue, 'hello'); + await expect.element(submit).toBeEnabled(); + + await userEvent.click(submit); + await expect.poll(() => onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].toString()).toBe('hello'); + }); + + it('shows a Stop button while generating and calls onStop', async () => { + let {onStop} = await renderPromptField({isGenerating: true}); + let stop = page.getByRole('button', {name: 'Stop'}); + await expect.element(stop).toBeEnabled(); + + await userEvent.click(stop); + await expect.poll(() => onStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('attachments', () => { + it('renders attachments and removes them', async () => { + let attachment = imageAttachment('a1'); + let {container, getAttachments, onRemoveAttachments} = await renderPromptField({ + attachments: [attachment] + }); + + await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); + let removeButton = container.querySelector('[slot="remove"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await userEvent.click(removeButton); + await expect.poll(() => onRemoveAttachments).toHaveBeenCalledTimes(1); + expect(onRemoveAttachments.mock.calls[0][0][0].id).toBe('a1'); + await expect.poll(() => getAttachments().length).toBe(0); + }); + + it('shows upload progress while uploading', async () => { + await renderPromptField({attachments: [imageAttachment('a1')], uploadProgress: 50}); + await expect.element(page.getByRole('progressbar', {name: 'Uploading'})).toBeInTheDocument(); + }); + + it('renders an attachment in the invalid state', async () => { + let {container} = await renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); + await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); + // The invalid state renders a decorative alert icon. + expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); + }); + }); + + describe('uncontrolled', () => { + it('renders default attachments and clears the field and attachments on submit', async () => { + let defaultValue = new PromptFieldValue([{type: 'text', text: 'hello'}]); + let {textbox, onSubmit} = await renderUncontrolledPromptField({ + defaultValue, + defaultAttachments: [imageAttachment('a1')] + }); + + // Default attachment renderer shows the thumbnail. + await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); + + let submit = page.getByRole('button', {name: 'Send'}); + await expect.element(submit).toBeEnabled(); + await userEvent.click(submit); + + await expect.poll(() => onSubmit).toHaveBeenCalledTimes(1); + // Uncontrolled field resets itself after submit. + await expect.poll(() => textbox.element().textContent).toBe(''); + await expect.poll(() => page.getByLabelText('Attachments').elements().length).toBe(0); + }); + }); +}); diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx new file mode 100644 index 00000000000..7948e3150a4 --- /dev/null +++ b/packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx @@ -0,0 +1,376 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + AttachFileMenuItem, + CommandMenuItem, + InsertMenuButton, + InsertTextMenuItem, + InsertTokenMenuItem, + PromptField, + PromptFieldAttachment, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptFieldValue, + PromptToken, + PromptTokenField +} from '../../src/PromptField'; +import {Attachment} from '../../src/AttachmentList'; +import { + Collection, + Header, + Heading, + Menu, + MenuItem, + MenuSection, + SubmenuTrigger, + Text +} from '@react-spectrum/s2/Menu'; +import {expect, type Mock, vi} from 'vitest'; +import {Image} from '@react-spectrum/s2/Image'; +import {type Locator, userEvent} from 'vitest/browser'; +import React, {useEffect, useState} from 'react'; +import {render} from 'vitest-browser-react'; +import {TokenFieldValue} from 'react-aria-components'; + +// Tiny transparent PNG so resolves without a network fetch. +export const TINY_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +// Completion data, trimmed from PromptField.stories.tsx. +export const slashCommands = [ + {command: '/audience-explainer', kind: 'skill', description: 'Explain an AEP audience'}, + {command: '/clear', kind: 'command', description: 'Clear the context'}, + {command: '/compact', kind: 'command', description: 'Summarize conversation history'}, + {command: '/feedback', kind: 'command', description: 'Submit feedback'} +]; + +export const objects = [ + { + section: 'Audiences', + type: 'audience', + items: [ + {kind: 'audience', title: 'New Customers'}, + {kind: 'audience', title: 'Returning Customers'} + ] + }, + { + section: 'Campaigns', + type: 'campaign', + items: [{kind: 'campaign', title: 'Spring Launch 2026'}] + }, + { + section: 'Journeys', + type: 'journey', + items: [ + {kind: 'journey', title: 'Welcome Flow'}, + {kind: 'journey', title: 'Abandoned Cart Recovery'} + ] + } +]; + +interface CompletionCallbacks { + valueType?: string | null; + onClear?: () => void; + onCompact?: () => void; +} + +export function renderCompletions( + filterValue: string, + callbacks?: CompletionCallbacks +): React.ReactNode[] | null { + if (filterValue.startsWith('/')) { + return slashCommands + .filter( + item => + item.command.includes(filterValue.slice(1)) && + (callbacks?.valueType ? item.kind === callbacks.valueType : true) + ) + .map(item => + item.command === '/clear' ? ( + + {item.command} + + ) : item.command === '/compact' ? ( + + {item.command} + + ) : item.command === '/feedback' ? ( + + {item.command} + + ) : ( + + {item.command} + + ) + ); + } else if (filterValue.startsWith('@')) { + return objects + .filter(section => (callbacks?.valueType ? section.type === callbacks.valueType : true)) + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + return matchingItems.length > 0 ? ( + +
+ {section.section} +
+ {matchingItems} +
+ ) : null; + }) + .filter((v): v is React.ReactElement => v != null); + } + return null; +} + +export interface HarnessOptions { + initialValue?: PromptFieldValue; + attachments?: PromptFieldAttachment[]; + isGenerating?: boolean; + placeholder?: string; + acceptedAttachmentTypes?: string[]; + /** Applied to every rendered attachment (for exercising the upload progress state). */ + uploadProgress?: number; + /** Renders every attachment in the invalid state. */ + invalid?: boolean; +} + +export interface HarnessSpies { + onSubmit: Mock; + onStop: Mock; + onClear: Mock; + onCompact: Mock; + onRemoveAttachments: Mock; +} + +interface ControlledPromptFieldProps extends HarnessOptions { + valueRef: React.MutableRefObject; + attachmentsRef: React.MutableRefObject; + spies: HarnessSpies; +} + +function ControlledPromptField(props: ControlledPromptFieldProps) { + let { + initialValue = new PromptFieldValue([]), + attachments: initialAttachments = [], + isGenerating, + placeholder, + acceptedAttachmentTypes = ['image/*'], + uploadProgress, + invalid, + valueRef, + attachmentsRef, + spies + } = props; + let [value, setValue] = useState(initialValue); + let [attachments, setAttachments] = useState(initialAttachments); + useEffect(() => { + valueRef.current = value; + }, [value, valueRef]); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments, attachmentsRef]); + + return ( + setValue(v as PromptFieldValue)} + attachments={attachments} + onAttachmentsChange={setAttachments} + isGenerating={isGenerating} + onStop={spies.onStop} + onSubmit={spies.onSubmit} + acceptedAttachmentTypes={acceptedAttachmentTypes} + onRemoveAttachments={spies.onRemoveAttachments}> + + {attachment => ( + + {attachment.image && } + + )} + + + renderCompletions(filterValue, { + valueType, + onClear: spies.onClear, + onCompact: spies.onCompact + }) + }> + {token => {token.text}} + + + + + + + Reference an object + + + {(item: (typeof objects)[number]) => ( + +
+ {item.section} +
+ + {(obj: {kind: string; title: string}) => ( + + {obj.title} + + )} + +
+ )} +
+
+
+ +
+
+ ); +} + +export interface PromptFieldHarness extends HarnessSpies { + getValue: () => PromptFieldValue; + getAttachments: () => PromptFieldAttachment[]; + textbox: Locator; + container: HTMLElement; +} + +export async function renderPromptField(options: HarnessOptions = {}): Promise { + let valueRef = {current: options.initialValue ?? new PromptFieldValue([])}; + let attachmentsRef = {current: options.attachments ?? []}; + let spies: HarnessSpies = { + onSubmit: vi.fn(), + onStop: vi.fn(), + onClear: vi.fn(), + onCompact: vi.fn(), + onRemoveAttachments: vi.fn() + }; + let screen = await render( + + ); + return { + ...spies, + getValue: () => valueRef.current, + getAttachments: () => attachmentsRef.current, + textbox: screen.getByRole('textbox', {name: 'Prompt'}), + container: screen.container + }; +} + +export interface UncontrolledHarness { + onSubmit: Mock; + textbox: Locator; + container: HTMLElement; +} + +/** + * Renders an uncontrolled PromptField (using defaultValue/defaultAttachments and the default + * attachment renderer) so submit-clears-the-field and default-render paths are exercised. + */ +export async function renderUncontrolledPromptField( + options: { + defaultValue?: PromptFieldValue; + defaultAttachments?: PromptFieldAttachment[]; + } = {} +): Promise { + let onSubmit = vi.fn(); + let screen = await render( + + + + {token => {token.text}} + + + + + + ); + return { + onSubmit, + textbox: screen.getByRole('textbox', {name: 'Prompt'}), + container: screen.container + }; +} + +/** Build an image attachment fixture backed by a real File. */ +export function imageAttachment(id: string, name = 'photo.png'): PromptFieldAttachment { + return {id, file: new File(['x'], name, {type: 'image/png'}), image: TINY_PNG}; +} + +export function tokenTexts(value: PromptFieldValue): string[] { + return value.segments.filter(s => s.type === 'token').map(s => s.text); +} + +export async function focusField(textbox: Locator): Promise { + await userEvent.click(textbox); + await expect.element(textbox).toHaveFocus(); +} + +export async function waitForFieldText( + getValue: () => PromptFieldValue, + str: string +): Promise { + await expect.poll(() => getValue().toString()).toBe(str); +} + +export async function waitForTokens( + getValue: () => PromptFieldValue, + tokens: string[] +): Promise { + await expect.poll(() => tokenTexts(getValue())).toEqual(tokens); +} + +export {PromptFieldValue, TokenFieldValue}; diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index d35139ef412..c302f5df80a 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -136,8 +136,11 @@ function iconWrapperPlugin(): Plugin { name: 'icon-wrapper', enforce: 'pre', resolveId(source) { - if (source.startsWith('@react-spectrum/s2/icons/')) { - const iconName = source.replace('@react-spectrum/s2/icons/', ''); + // Match both the bare specifier and the form produced after the + // `@react-spectrum/s2` -> exports alias rewrites it to `.../exports/icons/`. + const match = source.match(/(?:@react-spectrum\/s2|[\\/]exports)[\\/]icons[\\/](.+)$/); + if (match) { + const iconName = match[1]; if (iconMap.has(iconName)) { return VIRTUAL_PREFIX + iconName; } From b8c6b35b928f4f3a1c1d050c1164aa8db6fab006 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 11 Aug 2026 20:35:49 -0700 Subject: [PATCH 13/18] back compat --- packages/react-stately/src/tokenfield/TokenFieldValue.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-stately/src/tokenfield/TokenFieldValue.ts b/packages/react-stately/src/tokenfield/TokenFieldValue.ts index 8f63d96d702..96ac3caddeb 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -141,6 +141,10 @@ export class TokenFieldValue { return result; } + withCaretPosition(position: Position): this { + return this.withSelectedRange(new SelectedRange(position)); + } + private splitSegment( segment: TokenFieldSegment | undefined, offset: number From afe5e8807512f1dff0cb60757a901ede07cd762a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 09:46:58 -0700 Subject: [PATCH 14/18] raise resource class for browser tests --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9c118a4cff4..4143cad465b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -414,6 +414,7 @@ jobs: test-browser: docker: - image: mcr.microsoft.com/playwright:v1.60.0-noble + resource_class: 2xlarge.gen2 working_directory: /home/circleci/react-spectrum steps: - restore_cache: From 64d6499cc1af6c7415cf1fcf5415f4199234f1e2 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 09:47:53 -0700 Subject: [PATCH 15/18] lint --- .../ai/test/PromptField.browser.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx index 21014cb5c91..5e59882dff0 100644 --- a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx @@ -197,7 +197,12 @@ describeOrSkip('PromptField', () => { { type: 'token', text: 'New Customers', - value: {type: 'custom', anchor: '@', valueType: 'audience', data: {kind: 'audience', title: 'New Customers'}} + value: { + type: 'custom', + anchor: '@', + valueType: 'audience', + data: {kind: 'audience', title: 'New Customers'} + } } ]); let {textbox, getValue} = await renderPromptField({initialValue}); @@ -315,7 +320,10 @@ describeOrSkip('PromptField', () => { }); it('renders an attachment in the invalid state', async () => { - let {container} = await renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); + let {container} = await renderPromptField({ + attachments: [imageAttachment('a1')], + invalid: true + }); await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); // The invalid state renders a decorative alert icon. expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); From e599269fd1ff69cfc67c68cfc3ebf6d01dbb5aa8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 09:54:48 -0700 Subject: [PATCH 16/18] try keyboard? --- packages/@react-spectrum/ai/test/PromptField.browser.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx index 5e59882dff0..480840c2ddc 100644 --- a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx @@ -66,7 +66,7 @@ describeOrSkip('PromptField', () => { await expect.element(menuItem('Spring Launch 2026')).toBeInTheDocument(); await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); - await userEvent.click(menuItem('New Customers')); + await userEvent.keyboard('{ArrowDown}{Enter}'); // Token replaces the typed filter and a trailing space is added. await waitForTokens(getValue, ['New Customers']); await waitForFieldText(getValue, 'New Customers '); From 471bc82b14ef53df7772405ae9da97b7faa1cb92 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 13:09:42 -0700 Subject: [PATCH 17/18] run tests in jsdom instead of browser --- .../ai/test/PromptField.browser.test.tsx | 354 ------------------ .../ai/test/PromptField.test.tsx | 310 +++++++++++++++ ...wserUtils.tsx => promptFieldTestUtils.tsx} | 161 +++----- 3 files changed, 366 insertions(+), 459 deletions(-) delete mode 100644 packages/@react-spectrum/ai/test/PromptField.browser.test.tsx create mode 100644 packages/@react-spectrum/ai/test/PromptField.test.tsx rename packages/@react-spectrum/ai/test/utils/{promptFieldBrowserUtils.tsx => promptFieldTestUtils.tsx} (74%) diff --git a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx b/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx deleted file mode 100644 index 480840c2ddc..00000000000 --- a/packages/@react-spectrum/ai/test/PromptField.browser.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {describe, expect, it} from 'vitest'; -import { - focusField, - imageAttachment, - PromptFieldValue, - renderPromptField, - renderUncontrolledPromptField, - tokenTexts, - waitForFieldText, - waitForTokens -} from './utils/promptFieldBrowserUtils'; -import {page, userEvent} from 'vitest/browser'; -import React from 'react'; - -const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; - -// A prompt containing both a fillable object placeholder (Journey) and a free-text placeholder (Date). -function placeholderPrompt(): PromptFieldValue { - return new PromptFieldValue([ - {type: 'text', text: 'Detect audiences in '}, - { - type: 'token', - text: 'Journey', - value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'} - }, - {type: 'text', text: ' that changed in the past '}, - {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} - ]); -} - -let menuItem = (name: string | RegExp) => page.getByRole('menuitem', {name}); -let menuItemCount = (name: string | RegExp) => menuItem(name).elements().length; - -describeOrSkip('PromptField', () => { - describe('placeholder text', () => { - it('shows the placeholder when empty and hides it after typing', async () => { - let {textbox, getValue} = await renderPromptField({placeholder: 'Ask me anything'}); - expect(textbox.element()).toHaveAttribute('data-placeholder', 'Ask me anything'); - - await focusField(textbox); - await userEvent.keyboard('hi'); - await waitForFieldText(getValue, 'hi'); - }); - }); - - describe('autocomplete trigger: @', () => { - it('opens object completions and inserts a token', async () => { - let {textbox, getValue} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('@'); - - // All object sections are shown for a bare @ trigger. - await expect.element(menuItem('New Customers')).toBeInTheDocument(); - await expect.element(menuItem('Spring Launch 2026')).toBeInTheDocument(); - await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); - - await userEvent.keyboard('{ArrowDown}{Enter}'); - // Token replaces the typed filter and a trailing space is added. - await waitForTokens(getValue, ['New Customers']); - await waitForFieldText(getValue, 'New Customers '); - }); - - it('filters completions as the user types', async () => { - let {textbox} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('@New'); - - await expect.element(menuItem('New Customers')).toBeInTheDocument(); - await expect.poll(() => menuItemCount('Welcome Flow')).toBe(0); - }); - - it('does not trigger when @ is not preceded by whitespace or start', async () => { - let {textbox} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('hello@'); - - // No completion popover should open. - await expect.poll(() => page.getByRole('menu').elements().length).toBe(0); - }); - }); - - describe('autocomplete trigger: /', () => { - it('inserts a token via InsertTokenMenuItem', async () => { - let {textbox, getValue} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('/'); - - await expect.element(menuItem('/audience-explainer')).toBeInTheDocument(); - await expect.element(menuItem('/clear')).toBeInTheDocument(); - await expect.element(menuItem('/compact')).toBeInTheDocument(); - await expect.element(menuItem('/feedback')).toBeInTheDocument(); - - await userEvent.click(menuItem('/audience-explainer')); - await waitForTokens(getValue, ['/audience-explainer']); - }); - - it('inserts plain text via InsertTextMenuItem', async () => { - let {textbox, getValue} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('/feedback'); - - await userEvent.click(menuItem('/feedback')); - await waitForFieldText(getValue, '/feedback '); - expect(tokenTexts(getValue())).toEqual([]); - }); - - it('runs a callback and clears the filter via CommandMenuItem', async () => { - let {textbox, getValue, onCompact} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('/compact'); - - await userEvent.click(menuItem('/compact')); - await expect.poll(() => onCompact).toHaveBeenCalledTimes(1); - // Nothing inserted, and the filter text is cleared. - expect(tokenTexts(getValue())).toEqual([]); - expect(getValue().toString()).not.toContain('/compact'); - }); - - it('runs a callback via a plain MenuItem', async () => { - let {textbox, onClear} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('/clear'); - - await userEvent.click(menuItem('/clear')); - await expect.poll(() => onClear).toHaveBeenCalledTimes(1); - }); - }); - - describe('URL tokenization', () => { - it('auto-tokenizes a URL as it is typed', async () => { - let {textbox, getValue} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('visit test.com now'); - - await expect - .poll(() => getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')) - .toBe(true); - let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); - expect(urlToken?.text).toBe('test.com'); - }); - }); - - describe('insert (+) menu', () => { - it('inserts an object token from the Reference submenu', async () => { - let {textbox, getValue} = await renderPromptField(); - // Type first so the field has a live caret to insert at. - await focusField(textbox); - await userEvent.keyboard('Use '); - await userEvent.click(page.getByRole('button', {name: 'Add'})); - - await expect.element(menuItem('Attach a file')).toBeInTheDocument(); - let referenceItem = menuItem('Reference an object'); - await expect.element(referenceItem).toBeInTheDocument(); - - // Open the submenu. - await userEvent.hover(referenceItem); - await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); - await userEvent.click(menuItem('Welcome Flow')); - - await waitForTokens(getValue, ['Welcome Flow']); - await waitForFieldText(getValue, 'Use Welcome Flow '); - }); - - it('does not insert a double space when the caret is already followed by one', async () => { - let {textbox, getValue} = await renderPromptField(); - await focusField(textbox); - await userEvent.keyboard('x y'); - // Move the caret between 'x' and the space. - await userEvent.keyboard('{ArrowLeft}{ArrowLeft}'); - - await userEvent.click(page.getByRole('button', {name: 'Add'})); - await userEvent.hover(menuItem('Reference an object')); - await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); - await userEvent.click(menuItem('Welcome Flow')); - - // Reuses the existing trailing space rather than adding a second one. - await waitForFieldText(getValue, 'xWelcome Flow y'); - }); - }); - - describe('replacing an existing token', () => { - it('opens same-type completions when a custom token is selected', async () => { - let initialValue = new PromptFieldValue([ - {type: 'text', text: 'Analyze '}, - { - type: 'token', - text: 'New Customers', - value: { - type: 'custom', - anchor: '@', - valueType: 'audience', - data: {kind: 'audience', title: 'New Customers'} - } - } - ]); - let {textbox, getValue} = await renderPromptField({initialValue}); - await focusField(textbox); - - // Selecting the existing token opens completions filtered to the same type (audiences). - await userEvent.click(page.getByText('New Customers')); - await expect.element(menuItem('Returning Customers')).toBeInTheDocument(); - await expect.poll(() => menuItemCount('Welcome Flow')).toBe(0); - await expect.poll(() => menuItemCount('Spring Launch 2026')).toBe(0); - - // Choosing a different audience replaces the selected token. - await userEvent.click(menuItem('Returning Customers')); - await waitForTokens(getValue, ['Returning Customers']); - }); - }); - - describe('placeholders', () => { - it('moves between placeholders with Tab and Shift+Tab', async () => { - let {textbox, getValue} = await renderPromptField({initialValue: placeholderPrompt()}); - await focusField(textbox); - await userEvent.keyboard('{Home}'); - - // Tab selects the first placeholder (Journey, index 1). - await userEvent.keyboard('{Tab}'); - await expect.poll(() => getValue().selectedRange.start).toEqual({index: 1, offset: 0}); - await expect - .poll(() => getValue().selectedRange.end) - .toEqual({index: 1, offset: 'Journey'.length}); - - // Tab again selects the next placeholder (Date, index 3). - await userEvent.keyboard('{Tab}'); - await expect.poll(() => getValue().selectedRange.start).toEqual({index: 3, offset: 0}); - await expect - .poll(() => getValue().selectedRange.end) - .toEqual({index: 3, offset: 'Date'.length}); - - // Shift+Tab goes back to the Journey placeholder. - await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - await expect.poll(() => getValue().selectedRange.start).toEqual({index: 1, offset: 0}); - }); - - it('re-opens completions filtered to the placeholder value type when typed over', async () => { - let {textbox} = await renderPromptField({initialValue: placeholderPrompt()}); - await focusField(textbox); - await userEvent.keyboard('{Home}'); - await userEvent.keyboard('{Tab}'); // select the Journey placeholder - await userEvent.keyboard('W'); - - // Completions re-open, filtered to journeys only. - await expect.element(menuItem('Welcome Flow')).toBeInTheDocument(); - await expect.poll(() => menuItemCount('New Customers')).toBe(0); - }); - - it('selects the last placeholder when Shift+Tab-ing into the field', async () => { - let {textbox, getValue} = await renderPromptField({initialValue: placeholderPrompt()}); - await focusField(textbox); - // Caret past the last placeholder so Tab leaves the field instead of jumping placeholders. - await userEvent.keyboard('{End}'); - await userEvent.keyboard('{Tab}'); - // Re-enter from a following element; the last placeholder (Date, index 3) is auto-selected. - await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); - - await expect.poll(() => getValue().selectedRange.start).toEqual({index: 3, offset: 0}); - await expect.poll(() => getValue().selectedRange.end).toEqual({index: 3, offset: 1}); - }); - }); - - describe('submit / generate state', () => { - it('disables submit when empty and enables it with content', async () => { - let {textbox, getValue, onSubmit} = await renderPromptField(); - let submit = page.getByRole('button', {name: 'Send'}); - await expect.element(submit).toBeDisabled(); - - await focusField(textbox); - await userEvent.keyboard('hello'); - await waitForFieldText(getValue, 'hello'); - await expect.element(submit).toBeEnabled(); - - await userEvent.click(submit); - await expect.poll(() => onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0].toString()).toBe('hello'); - }); - - it('shows a Stop button while generating and calls onStop', async () => { - let {onStop} = await renderPromptField({isGenerating: true}); - let stop = page.getByRole('button', {name: 'Stop'}); - await expect.element(stop).toBeEnabled(); - - await userEvent.click(stop); - await expect.poll(() => onStop).toHaveBeenCalledTimes(1); - }); - }); - - describe('attachments', () => { - it('renders attachments and removes them', async () => { - let attachment = imageAttachment('a1'); - let {container, getAttachments, onRemoveAttachments} = await renderPromptField({ - attachments: [attachment] - }); - - await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); - let removeButton = container.querySelector('[slot="remove"]') as HTMLElement; - expect(removeButton).toBeInTheDocument(); - - await userEvent.click(removeButton); - await expect.poll(() => onRemoveAttachments).toHaveBeenCalledTimes(1); - expect(onRemoveAttachments.mock.calls[0][0][0].id).toBe('a1'); - await expect.poll(() => getAttachments().length).toBe(0); - }); - - it('shows upload progress while uploading', async () => { - await renderPromptField({attachments: [imageAttachment('a1')], uploadProgress: 50}); - await expect.element(page.getByRole('progressbar', {name: 'Uploading'})).toBeInTheDocument(); - }); - - it('renders an attachment in the invalid state', async () => { - let {container} = await renderPromptField({ - attachments: [imageAttachment('a1')], - invalid: true - }); - await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); - // The invalid state renders a decorative alert icon. - expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); - }); - }); - - describe('uncontrolled', () => { - it('renders default attachments and clears the field and attachments on submit', async () => { - let defaultValue = new PromptFieldValue([{type: 'text', text: 'hello'}]); - let {textbox, onSubmit} = await renderUncontrolledPromptField({ - defaultValue, - defaultAttachments: [imageAttachment('a1')] - }); - - // Default attachment renderer shows the thumbnail. - await expect.element(page.getByLabelText('Attachments')).toBeInTheDocument(); - - let submit = page.getByRole('button', {name: 'Send'}); - await expect.element(submit).toBeEnabled(); - await userEvent.click(submit); - - await expect.poll(() => onSubmit).toHaveBeenCalledTimes(1); - // Uncontrolled field resets itself after submit. - await expect.poll(() => textbox.element().textContent).toBe(''); - await expect.poll(() => page.getByLabelText('Attachments').elements().length).toBe(0); - }); - }); -}); diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx new file mode 100644 index 00000000000..e64ad075e11 --- /dev/null +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -0,0 +1,310 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, screen, waitFor} from '@react-spectrum/test-utils-internal'; +import {imageAttachment, installRangePolyfill, PromptFieldValue, renderPromptField, tokenTexts} from './utils/promptFieldTestUtils'; +import React from 'react'; + +// Suite requires React 19 (matches the TokenField browser coverage this ports from). +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; + +let findMenuItem = (name: string | RegExp) => screen.findByRole('menuitem', {name}); +let getMenuItem = (name: string | RegExp) => screen.getByRole('menuitem', {name}); +let queryMenuItem = (name: string | RegExp) => screen.queryByRole('menuitem', {name}); + +// A prompt containing both a fillable object placeholder (Journey) and a free-text placeholder (Date). +function placeholderPrompt(): PromptFieldValue { + return new PromptFieldValue([ + {type: 'text', text: 'Detect audiences in '}, + {type: 'token', text: 'Journey', value: {type: 'placeholder', placeholderType: 'token', anchor: '@', valueType: 'journey'}}, + {type: 'text', text: ' that changed in the past '}, + {type: 'token', text: 'Date', value: {type: 'placeholder', placeholderType: 'text'}} + ]); +} + +describeOrSkip('PromptField', () => { + beforeAll(() => { + installRangePolyfill(); + }); + + describe('placeholder text', () => { + it('shows the placeholder when empty and hides it after typing', async () => { + let {user, textbox, getValue} = renderPromptField({placeholder: 'Ask me anything'}); + expect(textbox).toHaveAttribute('data-placeholder', 'Ask me anything'); + + await user.click(textbox); + await user.keyboard('hi'); + expect(getValue().toString()).toBe('hi'); + }); + }); + + describe('autocomplete trigger: @', () => { + it('opens object completions and inserts a token', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@'); + + // All object sections are shown for a bare @ trigger. + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(getMenuItem('Spring Launch 2026')).toBeInTheDocument(); + expect(getMenuItem('Welcome Flow')).toBeInTheDocument(); + + await user.click(getMenuItem('New Customers')); + // Token replaces the typed filter and a trailing space is added. + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['New Customers'])); + expect(getValue().toString()).toBe('New Customers '); + }); + + it('filters completions as the user types', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('@New'); + + expect(await findMenuItem('New Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + }); + + it('does not trigger when @ is not preceded by whitespace or start', async () => { + let {user, textbox} = renderPromptField(); + await user.click(textbox); + await user.keyboard('hello@'); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + describe('autocomplete trigger: /', () => { + it('inserts a token via InsertTokenMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/'); + + expect(await findMenuItem('/audience-explainer')).toBeInTheDocument(); + expect(getMenuItem('/clear')).toBeInTheDocument(); + expect(getMenuItem('/compact')).toBeInTheDocument(); + expect(getMenuItem('/feedback')).toBeInTheDocument(); + + await user.click(getMenuItem('/audience-explainer')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['/audience-explainer'])); + }); + + it('inserts plain text via InsertTextMenuItem', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/feedback'); + + await user.click(await findMenuItem('/feedback')); + await waitFor(() => expect(getValue().toString()).toBe('/feedback ')); + expect(tokenTexts(getValue())).toEqual([]); + }); + + it('runs a callback and clears the filter via CommandMenuItem', async () => { + let {user, textbox, getValue, onCompact} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/compact'); + + await user.click(await findMenuItem('/compact')); + await waitFor(() => expect(onCompact).toHaveBeenCalledTimes(1)); + // Nothing inserted, and the filter text is cleared. + expect(tokenTexts(getValue())).toEqual([]); + expect(getValue().toString()).not.toContain('/compact'); + }); + + it('runs a callback via a plain MenuItem', async () => { + let {user, textbox, onClear} = renderPromptField(); + await user.click(textbox); + await user.keyboard('/clear'); + + await user.click(await findMenuItem('/clear')); + await waitFor(() => expect(onClear).toHaveBeenCalledTimes(1)); + }); + }); + + describe('URL tokenization', () => { + it('auto-tokenizes a URL as it is typed', async () => { + let {user, textbox, getValue} = renderPromptField(); + await user.click(textbox); + await user.keyboard('visit test.com now'); + + await waitFor(() => + expect(getValue().segments.some(s => s.type === 'token' && s.value?.type === 'url')).toBe(true) + ); + let urlToken = getValue().segments.find(s => s.type === 'token' && s.value?.type === 'url'); + expect(urlToken?.text).toBe('test.com'); + }); + }); + + describe('replacing an existing token', () => { + it('opens same-type completions when a custom token is selected', async () => { + let initialValue = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + { + type: 'token', + text: 'New Customers', + value: {type: 'custom', anchor: '@', valueType: 'audience', data: {kind: 'audience', title: 'New Customers'}} + } + ]); + let {user, textbox, getValue, setValue} = renderPromptField({initialValue}); + + // Select the token via the controlled value (jsdom can't click-select a token), then the + // completions open filtered to the same type (audiences). + await user.click(textbox); + act(() => setValue(v => v.withSelectedRange(new PromptFieldValue.SelectedRange({index: 1, offset: 0}, {index: 1, offset: 'New Customers'.length})) as PromptFieldValue)); + expect(await findMenuItem('Returning Customers')).toBeInTheDocument(); + expect(queryMenuItem('Welcome Flow')).not.toBeInTheDocument(); + expect(queryMenuItem('Spring Launch 2026')).not.toBeInTheDocument(); + + // Choosing a different audience replaces the selected token. + await user.click(getMenuItem('Returning Customers')); + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Returning Customers'])); + }); + }); + + describe('insert (+) menu', () => { + it('inserts an object token from the Reference submenu', async () => { + let {user, textbox, getValue} = renderPromptField(); + // Type first so the field has a live caret to insert at (typical usage). + await user.click(textbox); + await user.keyboard('Use '); + await user.click(screen.getByRole('button', {name: 'Add'})); + + expect(await findMenuItem('Attach a file')).toBeInTheDocument(); + let referenceItem = getMenuItem('Reference an object'); + + // Open the submenu. + await user.hover(referenceItem); + await user.click(await findMenuItem('Welcome Flow')); + + await waitFor(() => expect(tokenTexts(getValue())).toEqual(['Welcome Flow'])); + expect(getValue().toString()).toBe('Use Welcome Flow '); + }); + + it('does not insert a double space when the caret is already followed by one', async () => { + let {user, getValue, setValue} = renderPromptField(); + // Place the caret between 'x' and the space (jsdom can't move the caret via arrow keys). + act(() => setValue(new PromptFieldValue([{type: 'text', text: 'x y'}]).withSelectedRange(new PromptFieldValue.SelectedRange({index: 0, offset: 1})) as PromptFieldValue)); + + await user.click(screen.getByRole('button', {name: 'Add'})); + await user.hover(getMenuItem('Reference an object')); + await user.click(await findMenuItem('Welcome Flow')); + + // Reuses the existing trailing space rather than adding a second one. + await waitFor(() => expect(getValue().toString()).toBe('xWelcome Flow y')); + }); + }); + + describe('placeholders', () => { + it('moves between placeholders with Tab and Shift+Tab', async () => { + let {user, textbox, getValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + + // Tab selects the first placeholder (Journey, index 1). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 1, offset: 'Journey'.length}); + + // Tab again selects the next placeholder (Date, index 3). + await user.keyboard('{Tab}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + expect(getValue().selectedRange.end).toEqual({index: 3, offset: 'Date'.length}); + + // Shift+Tab goes back to the Journey placeholder. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 1, offset: 0})); + }); + + it('re-opens completions filtered to the placeholder value type when typed over', async () => { + let {user, textbox} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + await user.keyboard('{Home}'); + await user.keyboard('{Tab}'); // select the Journey placeholder + await user.keyboard('W'); + + // Completions re-open, filtered to journeys only. + expect(await findMenuItem('Welcome Flow')).toBeInTheDocument(); + expect(queryMenuItem('New Customers')).not.toBeInTheDocument(); + }); + + it('selects the last placeholder when Shift+Tab-ing into the field', async () => { + let {user, textbox, getValue, setValue} = renderPromptField({initialValue: placeholderPrompt()}); + await user.click(textbox); + // Put the caret past the last placeholder so Tab leaves the field instead of jumping + // placeholders (jsdom can't move the caret to the end via {End}). + act(() => setValue(v => v.withSelectedRange(new PromptFieldValue.SelectedRange({index: 3, offset: 'Date'.length})) as PromptFieldValue)); + await user.keyboard('{Tab}'); + // Re-enter from a following element; the last placeholder (Date, index 3) is auto-selected. + await user.keyboard('{Shift>}{Tab}{/Shift}'); + + await waitFor(() => expect(getValue().selectedRange.start).toEqual({index: 3, offset: 0})); + // The selection covers the last placeholder (the Date token). + let sel = getValue(); + expect(sel.slice(sel.selectedRange.start, sel.selectedRange.end).toString()).toBe('Date'); + }); + }); + + describe('submit / generate state', () => { + it('disables submit when empty and enables it with content', async () => { + let {user, textbox, getValue, onSubmit} = renderPromptField(); + let submit = screen.getByRole('button', {name: 'Send'}); + expect(submit).toBeDisabled(); + + await user.click(textbox); + await user.keyboard('hello'); + await waitFor(() => expect(getValue().toString()).toBe('hello')); + expect(submit).toBeEnabled(); + + await user.click(submit); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].toString()).toBe('hello'); + }); + + it('shows a Stop button while generating and calls onStop', async () => { + let {user, onStop} = renderPromptField({isGenerating: true}); + let stop = screen.getByRole('button', {name: 'Stop'}); + expect(stop).toBeEnabled(); + + await user.click(stop); + expect(onStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('attachments', () => { + it('renders attachments and removes them', async () => { + let attachment = imageAttachment('a1'); + let {user, container, getAttachments, onRemoveAttachments} = renderPromptField({ + attachments: [attachment] + }); + + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + let removeButton = container.querySelector('[slot="remove"]') as HTMLElement; + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + expect(onRemoveAttachments).toHaveBeenCalledTimes(1); + expect(onRemoveAttachments.mock.calls[0][0][0].id).toBe('a1'); + await waitFor(() => expect(getAttachments().length).toBe(0)); + }); + + it('shows upload progress while uploading', () => { + renderPromptField({attachments: [imageAttachment('a1')], uploadProgress: 50}); + expect(screen.getByRole('progressbar', {name: 'Uploading'})).toBeInTheDocument(); + }); + + it('renders an attachment in the invalid state', () => { + let {container} = renderPromptField({attachments: [imageAttachment('a1')], invalid: true}); + expect(screen.getByLabelText('Attachments')).toBeInTheDocument(); + // The invalid state renders a decorative alert icon. + expect(container.querySelector('[aria-hidden="true"] svg')).toBeTruthy(); + }); + }); +}); diff --git a/packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx similarity index 74% rename from packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx rename to packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx index 7948e3150a4..d09e90ef8a6 100644 --- a/packages/@react-spectrum/ai/test/utils/promptFieldBrowserUtils.tsx +++ b/packages/@react-spectrum/ai/test/utils/promptFieldTestUtils.tsx @@ -26,27 +26,33 @@ import { PromptTokenField } from '../../src/PromptField'; import {Attachment} from '../../src/AttachmentList'; -import { - Collection, - Header, - Heading, - Menu, - MenuItem, - MenuSection, - SubmenuTrigger, - Text -} from '@react-spectrum/s2/Menu'; -import {expect, type Mock, vi} from 'vitest'; +import {Collection, Header, Heading, Menu, MenuItem, MenuSection, SubmenuTrigger, Text} from '@react-spectrum/s2/Menu'; import {Image} from '@react-spectrum/s2/Image'; -import {type Locator, userEvent} from 'vitest/browser'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React, {useEffect, useState} from 'react'; -import {render} from 'vitest-browser-react'; import {TokenFieldValue} from 'react-aria-components'; +import userEvent from '@testing-library/user-event'; // Tiny transparent PNG so resolves without a network fetch. export const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; +/** + * jsdom doesn't implement Range.getBoundingClientRect / getClientRects, which the completion + * popover relies on for positioning. Install stubs so the popover can open. Call in beforeAll. + */ +export function installRangePolyfill(): void { + let proto = Range.prototype as any; + if (!proto.getBoundingClientRect) { + proto.getBoundingClientRect = () => ({ + x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} + }); + } + if (!proto.getClientRects) { + proto.getClientRects = () => ({length: 0, item: () => null, [Symbol.iterator]: function* () {}}); + } +} + // Completion data, trimmed from PromptField.stories.tsx. export const slashCommands = [ {command: '/audience-explainer', kind: 'skill', description: 'Explain an AEP audience'}, @@ -85,10 +91,7 @@ interface CompletionCallbacks { onCompact?: () => void; } -export function renderCompletions( - filterValue: string, - callbacks?: CompletionCallbacks -): React.ReactNode[] | null { +export function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks): React.ReactNode[] | null { if (filterValue.startsWith('/')) { return slashCommands .filter( @@ -167,16 +170,17 @@ export interface HarnessOptions { } export interface HarnessSpies { - onSubmit: Mock; - onStop: Mock; - onClear: Mock; - onCompact: Mock; - onRemoveAttachments: Mock; + onSubmit: jest.Mock; + onStop: jest.Mock; + onClear: jest.Mock; + onCompact: jest.Mock; + onRemoveAttachments: jest.Mock; } interface ControlledPromptFieldProps extends HarnessOptions { valueRef: React.MutableRefObject; attachmentsRef: React.MutableRefObject; + setValueRef: React.MutableRefObject>>; spies: HarnessSpies; } @@ -191,10 +195,14 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { invalid, valueRef, attachmentsRef, + setValueRef, spies } = props; let [value, setValue] = useState(initialValue); let [attachments, setAttachments] = useState(initialAttachments); + useEffect(() => { + setValueRef.current = setValue; + }, [setValue, setValueRef]); useEffect(() => { valueRef.current = value; }, [value, valueRef]); @@ -215,10 +223,7 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { onRemoveAttachments={spies.onRemoveAttachments}> {attachment => ( - + {attachment.image && } )} @@ -227,11 +232,7 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { placeholder={placeholder} completionTrigger={/(?<=^|\s)[@/]/} renderCompletions={(filterValue, valueType) => - renderCompletions(filterValue, { - valueType, - onClear: spies.onClear, - onCompact: spies.onCompact - }) + renderCompletions(filterValue, {valueType, onClear: spies.onClear, onCompact: spies.onCompact}) }> {token => {token.text}} @@ -249,14 +250,10 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { {item.section} - {(obj: {kind: string; title: string}) => ( + {(obj: {kind: string, title: string}) => ( + token={{type: 'token', text: obj.title, value: {type: 'custom', anchor: '@', valueType: obj.kind, data: obj}}}> {obj.title} )} @@ -273,75 +270,48 @@ function ControlledPromptField(props: ControlledPromptFieldProps) { } export interface PromptFieldHarness extends HarnessSpies { + user: ReturnType; getValue: () => PromptFieldValue; getAttachments: () => PromptFieldAttachment[]; - textbox: Locator; + /** + * The controlled value setter. jsdom can't drive caret/token selection through the + * contenteditable (that needs Selection.modify / hit-testing, covered by TokenField's own + * browser tests), so tests position the caret/selection through the controlled value instead. + */ + setValue: React.Dispatch>; + textbox: HTMLElement; container: HTMLElement; } -export async function renderPromptField(options: HarnessOptions = {}): Promise { +export function renderPromptField(options: HarnessOptions = {}): PromptFieldHarness { + let user = userEvent.setup({delay: null, pointerMap}); let valueRef = {current: options.initialValue ?? new PromptFieldValue([])}; let attachmentsRef = {current: options.attachments ?? []}; + let setValueRef = {current: (() => {}) as React.Dispatch>}; let spies: HarnessSpies = { - onSubmit: vi.fn(), - onStop: vi.fn(), - onClear: vi.fn(), - onCompact: vi.fn(), - onRemoveAttachments: vi.fn() + onSubmit: jest.fn(), + onStop: jest.fn(), + onClear: jest.fn(), + onCompact: jest.fn(), + onRemoveAttachments: jest.fn() }; - let screen = await render( + let tree = render( ); return { ...spies, + user, getValue: () => valueRef.current, getAttachments: () => attachmentsRef.current, - textbox: screen.getByRole('textbox', {name: 'Prompt'}), - container: screen.container - }; -} - -export interface UncontrolledHarness { - onSubmit: Mock; - textbox: Locator; - container: HTMLElement; -} - -/** - * Renders an uncontrolled PromptField (using defaultValue/defaultAttachments and the default - * attachment renderer) so submit-clears-the-field and default-render paths are exercised. - */ -export async function renderUncontrolledPromptField( - options: { - defaultValue?: PromptFieldValue; - defaultAttachments?: PromptFieldAttachment[]; - } = {} -): Promise { - let onSubmit = vi.fn(); - let screen = await render( - - - - {token => {token.text}} - - - - - - ); - return { - onSubmit, - textbox: screen.getByRole('textbox', {name: 'Prompt'}), - container: screen.container + setValue: (...args) => setValueRef.current(...args), + textbox: tree.getByRole('textbox', {name: 'Prompt'}), + container: tree.container }; } @@ -354,23 +324,4 @@ export function tokenTexts(value: PromptFieldValue): string[] { return value.segments.filter(s => s.type === 'token').map(s => s.text); } -export async function focusField(textbox: Locator): Promise { - await userEvent.click(textbox); - await expect.element(textbox).toHaveFocus(); -} - -export async function waitForFieldText( - getValue: () => PromptFieldValue, - str: string -): Promise { - await expect.poll(() => getValue().toString()).toBe(str); -} - -export async function waitForTokens( - getValue: () => PromptFieldValue, - tokens: string[] -): Promise { - await expect.poll(() => tokenTexts(getValue())).toEqual(tokens); -} - export {PromptFieldValue, TokenFieldValue}; From aaeee3b8c538f288f1fa4692abe5601c24c0ab08 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Aug 2026 13:47:36 -0700 Subject: [PATCH 18/18] only set selection when it has changed --- .../react-aria/src/tokenfield/useTokenField.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index fa9e8b0296b..3dfdb1eff2e 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -397,10 +397,7 @@ export function useTokenField( announceToken(value, range); - // Update the selected range in the value. Also update the ref so the layout - // effect does not re-apply this selection back to the DOM, which would clobber - // the browser's native selection direction (e.g. directionless double-click). - selectedRange.current = range; + // Update the selected range in the value. state.setValue(value => value.withSelectedRange(range)); }); @@ -712,7 +709,16 @@ export function setTokenFieldSelection( let [anchorNode, anchorOffset] = getDOMPosition(root, selectedRange.anchor); let [focusNode, focusOffset] = getDOMPosition(root, selectedRange.current); root[isProgrammaticSelectionChange] = !fireEvent; - selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + + // Only set selection if it has changed, because this can clobber the browser's selection direction. + if ( + selection.anchorNode !== anchorNode || + selection.anchorOffset !== anchorOffset || + selection.focusNode !== focusNode || + selection.focusOffset !== focusOffset + ) { + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); + } } }