diff --git a/.gitignore b/.gitignore index 178fe850797..ac8c322e491 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ starters/docs/registry starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock +.scout/ diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index c67d6c9945e..da3e9cb4f49 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -28,13 +28,15 @@ import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; import Cross from '../ui-icons/Cross'; -import {forwardRef, ReactNode, useRef} from 'react'; +import {forwardRef, ReactNode, useContext, useRef} from 'react'; +import {IconContext} from '@react-spectrum/s2/Icon'; import {ImageContext} from '@react-spectrum/s2/Image'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {Provider} from 'react-aria-components/slots'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, @@ -217,6 +219,56 @@ const attachmentErrorStyles = style({ } }); +function AttachmentContextProvider({ + children, + isUploading +}: { + children: ReactNode; + isUploading: boolean; +}) { + let imageCtx = useContext(ImageContext); + let iconCtx = useContext(IconContext); + const opacityStyles = style({ + opacity: {default: 1, isUploading: 0.15}, + transition: 'default' + })({isUploading}); + const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; + const iconSlots = iconCtx && 'slots' in iconCtx ? iconCtx.slots : undefined; + + return ( + + {children} + + ); +} + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -266,29 +318,10 @@ export const Attachment = forwardRef(function Attachment( /> )} - {/* Reduce opacity of the thumbnail if upload is in progress */} - - {ctx => ( - - {typeof children === 'function' ? children({size}) : children} - - )} - + + {typeof children === 'function' ? children({size}) : children} + {isInvalid && ( @@ -483,10 +487,12 @@ export interface PromptTokenFieldPopoverProps extends Omit; isFocused?: boolean; + // TODO: temp for coworker see above comment + menuWidth?: number; } function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { - let {filterAnchor, items, isFocused} = props; + let {filterAnchor, items, isFocused, menuWidth} = props; let {inputRef} = useContext(PromptFieldContext); let resolvedItems = items instanceof Promise ? use(items) : items; @@ -506,6 +512,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { isNonModal hideArrow placement="bottom start" + UNSAFE_style={menuWidth != null ? {width: menuWidth} : undefined} getTargetRect={target => { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> @@ -611,10 +618,11 @@ export interface PromptFieldVoiceButtonProps { lang?: string; isDisabled?: boolean; onError?: (code: VoiceInputErrorCode) => void; + onToggle?: (isListening: boolean) => void; } export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { - let {lang: langProp, isDisabled: isDisabledProp, onError} = props; + let {lang: langProp, isDisabled: isDisabledProp, onError, onToggle} = props; let {locale} = useLocale(); let lang = langProp ?? locale; let {prompt, setPrompt, inputRef, setListening} = useContext(PromptFieldContext); @@ -646,14 +654,20 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { setPrompt(finalPrompt); }); + let onToggleEvent = useEffectEvent((isListening: boolean) => { + onToggle?.(isListening); + }); + let wasListeningRef = useRef(false); useEffect(() => { if (isVoiceListening) { updateBasePrompt(); wasListeningRef.current = true; + onToggleEvent(true); } else if (wasListeningRef.current) { wasListeningRef.current = false; restoreFocus(); + onToggleEvent(false); } }, [isVoiceListening]); diff --git a/packages/@react-spectrum/ai/src/useDOMRef.ts b/packages/@react-spectrum/ai/src/useDOMRef.ts index 8b3afaf7881..7cc24518b7c 100644 --- a/packages/@react-spectrum/ai/src/useDOMRef.ts +++ b/packages/@react-spectrum/ai/src/useDOMRef.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {DOMRef, DOMRefValue, RefObject} from '@react-types/shared'; +import {DOMRef, DOMRefValue, FocusableRef, FocusableRefValue, RefObject} from '@react-types/shared'; import {useImperativeHandle, useRef} from 'react'; export function createDOMRef( @@ -23,6 +23,21 @@ export function createDOMRef( }; } +export function createFocusableRef( + domRef: RefObject, + focusableRef?: RefObject +): FocusableRefValue { + let resolvedFocusableRef = focusableRef || domRef; + return { + ...createDOMRef(domRef), + focus() { + if (resolvedFocusableRef.current) { + resolvedFocusableRef.current.focus(); + } + } + }; +} + export function useDOMRef( ref: DOMRef ): RefObject { @@ -30,3 +45,12 @@ export function useDOMRef( useImperativeHandle(ref, () => createDOMRef(domRef)); return domRef; } + +export function useFocusableRef( + ref: FocusableRef, + focusableRef?: RefObject +): RefObject { + let domRef = useRef(null); + useImperativeHandle(ref, () => createFocusableRef(domRef, focusableRef)); + return domRef; +} diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index cae286fa369..57dc6287524 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -13,6 +13,8 @@ import {Attachment as AttachmentComponent, AttachmentList} from '../src/AttachmentList'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Content} from '@react-spectrum/s2/Content'; +import File from '@react-spectrum/s2/icons/File'; +import FileText from '@react-spectrum/s2/icons/FileText'; import {Image} from '@react-spectrum/s2/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -99,3 +101,67 @@ function AttachmentListRender(args) { export const AIAttachmentList: Story = { render: args => }; + +function NonImageAttachmentListRender(args) { + let {isInvalid, size, uploadProgress, ...listArgs} = args; + return ( + + {/* TODO: what should the thumbnail only look like with icons */} + + + + + + + notes.txt + Plain text document + + + + + + data.csv + + + + ); +} + +export const NonImageAttachments: Story = { + render: args => +}; + +export const LongContents: Story = { + name: 'Long contents', + render: (args: any) => ( + + + + + Very long file name that exceeds the container width.pdf + + Long long long long long long long long long long description. + + + + + ) +}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 132b78c9d59..e8c004ba22d 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -43,6 +43,7 @@ import { import {Content} from '@react-spectrum/s2/Content'; import Data from '@react-spectrum/s2/icons/Data'; import * as data from '../src/loader/data'; +import type {FocusableRefValue} from '@react-types/shared'; import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Image} from '@react-spectrum/s2/Image'; import LinkIcon from '@react-spectrum/s2/icons/Link'; @@ -89,6 +90,10 @@ const meta: Meta = { placeholder: { control: 'text', table: {category: 'PromptTokenField'} + }, + menuWidth: { + control: 'number', + table: {category: 'PromptTokenField'} } }, args: { @@ -97,6 +102,7 @@ const meta: Meta = { attachmentVariant: 'thumbnail', attachmentInvalid: false, placeholder: undefined, + menuWidth: undefined, ...getActionArgs(events) }, title: 'AI/PromptField', @@ -245,35 +251,45 @@ interface UploadState { progress?: number; } +function atEnd(v: PromptFieldValue) { + let segs = v.segments; + return {index: segs.length - 1, offset: segs[segs.length - 1].text.length}; +} + +let prompt1 = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + {type: 'text', text: ' and suggest targeting strategies'} +]); + +let prompt2 = new PromptFieldValue([ + {type: 'text', text: 'Write a brief for '}, + { + type: 'token', + text: 'Spring Launch 2026', + value: {type: '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'}} ]); -let prompt3End = { - index: 1, - offset: prompt3Base.segments[1].text.length -}; let prompts = [ - new PromptFieldValue([ - {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, - {type: 'text', text: ' and suggest targeting strategies'} - ]), - new PromptFieldValue([ - {type: 'text', text: 'Write a brief for '}, - { - type: 'token', - text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} - } - ]), - prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com ') + prompt1.withCaretPosition(atEnd(prompt1)), + prompt2.withCaretPosition(atEnd(prompt2)), + prompt3Base.replaceRange( + atEnd(prompt3Base), + atEnd(prompt3Base), + ' journey performance from test.com /' + ) ]; function EverythingRender(args) { - let {placeholder, ...otherArgs} = args; + let {placeholder, menuWidth, ...otherArgs} = args; let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); let historyRef = useRef([]); @@ -352,13 +368,19 @@ function EverythingRender(args) {
{prompts.map((prompt, i) => ( - setValue(prompt)}> + { + setValue(prompt); + promptFieldRef.current?.focus(); + }}> {prompt.toString()} ))} {segment => ( @@ -508,7 +531,7 @@ function EverythingRender(args) { {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */}
- +