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 d9306e539e4..730a4b6c0f4 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -14,22 +14,16 @@ 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 { - 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 {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { createContext, createRef, forwardRef, use, + useCallback, useContext, useDeferredValue, useEffect, @@ -38,6 +32,7 @@ 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 @@ -51,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 @@ -87,9 +83,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 +103,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 +142,80 @@ 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'; + /** 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 = + | 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') || + token.value?.type === 'custom') && + 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 +401,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 +423,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { menuWidth, onKeyDown: onKeyDownProp } = props; - let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp}); let { prompt, setPrompt, @@ -371,24 +437,76 @@ 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 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') || + segment.value?.type === 'custom') + ) { + return [prompt.selectedRange.start, segment.value.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 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); + + // 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 +617,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 +633,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 && 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; @@ -519,7 +689,7 @@ export interface PromptTokenFieldPopoverProps extends Omit 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(); }}> - + {menuItems} @@ -550,6 +734,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { } export interface PromptTokenProps extends Omit { + token: TokenSegment; children: React.ReactNode; } @@ -557,40 +742,50 @@ export function PromptToken(props: PromptTokenProps) { return ( + className={renderProps => + style({ + font: 'ui', + backgroundColor: { + default: 'transparent-overlay-1000/10', + isSelected: 'blue-800', + // Firefox ignores completely transparent selection colors, so we need to use a nearly transparent color instead + '::selection': '[#ffffff01]' + }, + 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, + // 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' + } + })({...renderProps, isPlaceholder: props.token.value?.type === 'placeholder'}) + }> {icon} + styles: style({ + size: 14, + display: 'inline-block', + verticalAlign: '[-0.18em]', + marginEnd: 4 + }) }}> {props.children} @@ -676,7 +871,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); }); @@ -801,37 +996,50 @@ export function AttachFileMenuItem() { } // either replace the filter text (aka token replace) or insert value at current caret position (aka plain text inject) -function useInsertPromptSegment(buildSegments: (item: any) => TokenFieldSegment[]) { +function useInsertPromptSegment(segments: TokenFieldSegment[]) { let {setPrompt, inputRef} = useContext(PromptFieldContext); let anchor = useContext(PromptCompletionAnchorContext); - let pendingCaret = useRef(null); - return (item: any) => { + 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 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, - value.caretPosition, - buildSegments(item), + 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; - 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 - 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); } @@ -850,19 +1058,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?.(); }} /> @@ -881,18 +1089,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?.(); }} /> @@ -916,12 +1125,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 e8c004ba22d..64032ea3a23 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.valueType]; + } +} + 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; } @@ -188,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' ? ( @@ -204,14 +228,21 @@ 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} @@ -219,11 +250,19 @@ function renderCompletions(filterValue: string, callbacks?: CompletionCallbacks) ); } 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} )); @@ -258,7 +297,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', anchor: '@', valueType: 'audience', data: {title: 'New Customers'}} + }, {type: 'text', text: ' and suggest targeting strategies'} ]); @@ -267,23 +310,42 @@ let prompt2 = new PromptFieldValue([ { type: 'token', text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} + value: {type: 'custom', anchor: '@', valueType: 'campaign', data: {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', anchor: '@', valueType: 'journey', data: {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.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), - ' journey performance from test.com /' - ) + ' journey performance from test.com ' + ), + prompt4 ]; function EverythingRender(args) { @@ -374,7 +436,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 +527,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 +556,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 +598,15 @@ function EverythingRender(args) { Skills - item.type === 'skill')}> + item.kind === 'skill')}> {item => ( - + {item.command} {item.description} @@ -521,7 +626,15 @@ function EverythingRender(args) { {item => ( - {item.title} + + {item.title} + )} @@ -568,10 +681,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} )} 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/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/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 aef7c40d9f2..fa9e8b0296b 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -20,12 +20,13 @@ 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 { Position, + SelectedRange, TokenFieldProps, TokenFieldSegment, TokenFieldState, @@ -175,19 +176,15 @@ export function useTokenField( nextValue.current = value; }); - let caretPosition = useRef(null); + let selectedRange = useRef(null); useLayoutEffect(() => { - if ( - ref.current && - value.caretPosition && - !state.isComposing && - value.caretPosition !== caretPosition.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))) { - setCursor(ref.current, value.caretPosition); + setTokenFieldSelection(ref.current, value.selectedRange); + announceToken(value); } - caretPosition.current = value.caretPosition; + selectedRange.current = value.selectedRange; } }); @@ -393,20 +390,32 @@ 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]; - if (segment?.type !== 'token') { - segment = value.segments[start.index - 1]; - } - if (segment?.type === 'token') { - announce(segment.text, 'assertive'); - } + let range = getSelectedRange(ref.current!); + if (!range) { + return; + } - // Update the caret position in the value. - state.setValue(value => value.withCaretPosition(end)); - } + 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; + 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(value.caretPosition)) + ); } }); @@ -423,7 +432,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,13 +622,34 @@ 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 || + !nodeContains(container, selection.anchorNode) || + !nodeContains(container, selection.focusNode) + ) { + return null; + } + let anchor = getPosition(container, selection.anchorNode, selection.anchorOffset, false); + 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); - 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, !range.collapsed); 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}; } @@ -636,7 +666,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; @@ -666,57 +696,75 @@ 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); } } 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 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); +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 { - range.setStartBefore(startChild); + return [child.firstChild!, 0]; } - } else { - range.setStart(startChild, start.offset); } + return getDOMPosition(root, pos); +} + +function createDOMRange(root: Element, start: Position, end: Position): Range { + let range = document.createRange(); + 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. + // This is necessary for composition events. + 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 { @@ -832,3 +880,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.start, range.end).segments; + if (selected.length === 1 && selected[0].type === 'token') { + announce(selected[0].text, 'assertive'); + } + } +} 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..96ac3caddeb 100644 --- a/packages/react-stately/src/tokenfield/TokenFieldValue.ts +++ b/packages/react-stately/src/tokenfield/TokenFieldValue.ts @@ -31,13 +31,66 @@ 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; + } + + /** 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) { + 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 + ); + } +} + +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 } export interface TokenFieldValueOptions { - caretPosition?: Position | null; + selectedRange?: SelectedRange | null; } /** @@ -45,11 +98,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 +112,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,23 +123,28 @@ 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; return result; } + withCaretPosition(position: Position): this { + return this.withSelectedRange(new SelectedRange(position)); + } + private splitSegment( segment: TokenFieldSegment | undefined, offset: number @@ -174,14 +233,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 +363,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 +382,7 @@ export class TokenFieldValue { ); } - return this; + return this.withSelectedRange(new SelectedRange(position)); } /** Create a new list containing a subset of the segments. */ 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; }