diff --git a/packages/@react-spectrum/s2-ai/src/Thread.tsx b/packages/@react-spectrum/s2-ai/src/Thread.tsx new file mode 100644 index 00000000000..731b4747764 --- /dev/null +++ b/packages/@react-spectrum/s2-ai/src/Thread.tsx @@ -0,0 +1,332 @@ +/* + * 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 {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import {ButtonContext} from 'react-aria-components/Button'; +import { + createContext, + forwardRef, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import type {CSSProperties} from 'react'; +import {DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import {DOMRef, forwardRefType} from '@react-types/shared'; +import { + GridList, + GridListItem, + GridListItemProps, + GridListProps +} from 'react-aria-components/GridList'; +import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import {TextFieldContext} from 'react-aria-components/TextField'; +import {useDOMRef} from './useDOMRef'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; + +interface InternalThreadContextValue { + announceItem: (text: string) => void; + setGridListFocused: (isFocused: boolean) => void; + setIsNearBottom: (isNear: boolean) => void; + setScrollElement: (element: HTMLElement | null) => void; +} + +const InternalThreadContext = createContext({ + announceItem: text => announce(text, 'polite'), + setGridListFocused: () => {}, + setIsNearBottom: () => {}, + setScrollElement: () => {} +}); + +interface ThreadScrollButtonContextValue { + isNearBottom: boolean; + scrollToBottom: () => void; +} + +const ThreadScrollButtonContext = createContext({ + isNearBottom: true, + scrollToBottom: () => {} +}); + +// TODO: make this more RAC like (aka default class name and other RAC prop) +interface ThreadProps { + className?: string; + style?: CSSProperties; + children?: ReactNode; +} + +// TODO: things to look at +// chatgpt, claude, other AI assistants to see their UX +// they each don't seem to use column-reverse + +// TODO: things to figure out/try +// tabbing is a bit broken as well since we hit the child elements of the gridlist rows in opposite order... This seems to be due to the +// tabIndex = 0 of the ToggleButtons in the ToggleButtonGroup +// also since we track the last focused key of the Gridlist, you get a experience where you might tab in, go to the input field to add some messages +// and tab back to the Gridlist but get returned to your last focused key instead of to the newest message +// maybe we could do something like force that the last item is the internal focusedKey, always updating this to the latest last child +// whenever items update AND focus is not within the gridlist + +// TODO: things to handle later +// virtualizer layout +// weird behavior where the prompt field loses focus everytime you enter something +// make prompt field accept enter to submit the prompt, and have Option + Enter make a new line instead, mimics +// other ai chat experiences + +export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thread( + props: ThreadProps, + ref: DOMRef +) { + let {children, className, style} = props; + let domRef = useDOMRef(ref); + let isGridListFocusedRef = useRef(false); + let isFieldFocusedRef = useRef(false); + let hasNewMessagesRef = useRef(false); + let timeout = useRef | null>(null); + + let scrollRef = useRef(null); + let scrollToBottom = useCallback(() => { + scrollRef.current?.scrollTo({top: 0, behavior: 'smooth'}); + }, []); + let [isNearBottom, setIsNearBottom] = useState(true); + + // only announce new items if user is in the prompt field, otherwise if they + // are in the thread only announce there are new responses. If not in thread, don't announce + let announceItem = useCallback((text: string) => { + if (isGridListFocusedRef.current) { + // TODO: ideally announce number of new messages, but only count system messages? maybe threaditem needs + // to have a "type" prop + if (!hasNewMessagesRef.current) { + hasNewMessagesRef.current = true; + announce('New message', 'polite'); + // TODO: arbirary amount of time to wait before announcing new message, maybe we don't clear until + // we detect they scroll down? Or maybe when we do the message count we do it after a certain number of messages? + // or maybe this is fine + timeout.current = setTimeout(() => { + hasNewMessagesRef.current = false; + timeout.current = null; + }, 5000); + } + return; + } + + if (isFieldFocusedRef.current) { + announce(text, 'polite'); + } + }, []); + + let setGridListFocused = useCallback((isFocused: boolean) => { + isGridListFocusedRef.current = isFocused; + }, []); + + let setScrollElement = useCallback((el: HTMLElement | null) => { + scrollRef.current = el; + }, []); + + useEffect(() => { + return () => { + if (timeout.current !== null) { + clearTimeout(timeout.current); + } + }; + }, []); + + return ( + { + isFieldFocusedRef.current = focused; + } + } + } + } + ] + ]}> +
+ {children} +
+
+ ); +}); + +interface ThreadListProps extends Pick< + GridListProps, + 'items' | 'children' | 'focusOnEntry' | 'aria-label' | 'aria-labelledby' +> { + className?: string; +} + +export function ThreadList(props: ThreadListProps) { + let { + items, + children, + className, + focusOnEntry, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby + } = props; + + let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalThreadContext); + let isNearBottomRef = useRef(true); + let gridListRef = useRef(null); + + let callbackRef = useCallback( + (el: HTMLDivElement | null) => { + gridListRef.current = el; + setScrollElement(el); + }, + [setScrollElement] + ); + + // TODO: gridlist doesn't have onFocus/onBlur + useEffect(() => { + let el = gridListRef.current; + if (!el) { + return; + } + + let onFocusIn = () => setGridListFocused(true); + let onFocusOut = (e: FocusEvent) => { + if (!nodeContains(el, e.relatedTarget as Node)) { + setGridListFocused(false); + } + }; + + el.addEventListener('focusin', onFocusIn); + el.addEventListener('focusout', onFocusOut); + return () => { + el.removeEventListener('focusin', onFocusIn); + el.removeEventListener('focusout', onFocusOut); + }; + }, [setGridListFocused]); + + let handleScroll = useCallback(() => { + let el = gridListRef.current; + if (!el) { + return; + } + + // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up + let nearBottom = el.scrollTop > -100; + isNearBottomRef.current = nearBottom; + setIsNearBottom(nearBottom); + }, [setIsNearBottom]); + + useEffect(() => { + // scrolls to bottom on first render cuz we initialize isNearBottomRef to true, + // otherwise handles scrolling new prompts/etc into view unless you are scrolled up above + // 100px + // TODO: seems like other chat agents will scroll you down regardless of where you are in the chat + // however, as it is streaming the response in, it will allow you to scroll where ever and not pull you back down + if (isNearBottomRef.current) { + requestAnimationFrame(() => { + if (gridListRef.current) { + gridListRef.current.scrollTop = 0; + } + }); + } + }, [items]); + + return ( + + {children} + + ); +} + +interface ThreadScrollButtonProps { + children?: ReactNode; +} + +// TODO: wrapper so we can do the "if isNearBottom then hide" logic, could do this via inline styles perhaps +// and ditch the wrapper? +export function ThreadScrollButton({children}: ThreadScrollButtonProps) { + let {isNearBottom, scrollToBottom} = useContext(ThreadScrollButtonContext); + + if (isNearBottom) { + return null; + } + + return ( + + {children} + + ); +} + +interface ThreadItemProps extends Pick { + /** Whether or not the item's content is currently being streamed in. */ + isStreaming?: boolean; + /** Announce textValue on mount even when isStreaming is provided. */ + shouldAnnounceOnMount?: boolean; +} + +export function ThreadItem(props: ThreadItemProps) { + let {className, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props; + let {announceItem} = useContext(InternalThreadContext); + + // TODO: using aria-live on the gridlist item was pretty chatty and the streaming causes the text announcement + // to constantly reset. If we used a live region and updated its contents when streaming finished that worked decently + // but still feels quite verbose. Stick with this and get feedback + useLayoutEffect(() => { + if ((isStreaming === undefined || shouldAnnounceOnMount) && textValue && textValue !== ' ') { + announceItem(textValue); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + let isStreamingNow = isStreaming ?? false; + let prevStreamingRef = useRef(isStreamingNow); + useLayoutEffect(() => { + if (isStreaming === undefined) { + return; + } + let wasStreaming = prevStreamingRef.current; + prevStreamingRef.current = isStreamingNow; + if (wasStreaming && !isStreamingNow && textValue && textValue !== ' ') { + announceItem(textValue); + } + }, [isStreaming, isStreamingNow, textValue, announceItem]); + + return ( + + {children} + + ); +} diff --git a/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx new file mode 100644 index 00000000000..15eeca5e20f --- /dev/null +++ b/packages/@react-spectrum/s2-ai/stories/Thread.stories.tsx @@ -0,0 +1,1093 @@ +/* + * 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 {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; +import {AssetCard, Card, CardPreview} from '@react-spectrum/s2/Card'; +import {baseColor, css, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Button} from '@react-spectrum/s2/Button'; +import { + ButtonContext, + GridList, + Group, + isFileDropItem, + Label, + Tag, + TagGroup, + TagList, + TextArea, + TextField, + useDrop +} from 'react-aria-components'; +import CheckmarkCircle from '@react-spectrum/s2/icons/CheckmarkCircle'; +import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; +import ChevronRight from '@react-spectrum/s2/icons/ArrowCurved'; +import {CloseButton} from '@react-spectrum/s2/CloseButton'; +import {Content} from '@react-spectrum/s2/Content'; +import { + Disclosure, + DisclosureHeader, + DisclosurePanel, + DisclosureTitle +} from '@react-spectrum/s2/Disclosure'; +import {Image} from '@react-spectrum/s2/Image'; +import {Link, LinkProps} from '@react-spectrum/s2/Link'; +import {ListLayout} from 'react-stately/useVirtualizerState'; +import {MenuItem} from '@react-spectrum/s2/Menu'; +import type {Meta} from '@storybook/react'; +import Plus from '@react-spectrum/s2/icons/Add'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {ReactNode, useRef, useState} from 'react'; +import Send from '@react-spectrum/s2/icons/ArrowUpSend'; +import {Text} from '@react-spectrum/s2/Text'; +import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; +import ThumbDown from '@react-spectrum/s2/icons/ThumbDown'; +import ThumbUp from '@react-spectrum/s2/icons/ThumbUp'; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; +import {ToggleButtonGroup} from '@react-spectrum/s2/ToggleButtonGroup'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; + +const meta: Meta = { + component: Thread, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'S2-AI/Thread', + decorators: [ + Story => ( +
+ +
+ ) + ] +}; + +export default meta; + +export function StaticThread() { + // TODO: problem with this is that we are applying column reverse so tabbing into the collection brings you to the "bottom" item + // but in the static case we would need to flip the order of the static children as well. Maybe unrealistic for a static case to be + // used, but maybe people will do a .map? + // the dynamic case is fine cuz we can flip the order of the items inside Thread + return ( +
+ + + +

What would you like to do next?

+
+ + + +
+
+ + + Can you help me create a 45-minute presentation, with animations, for an executive + update? + + +
+

+ Big idea/core narrative: The warmth of welcome +

+

+ Hospitality begins the moment our customers set foot off their plane. We are more + than accommodation, and we service a diverse base. We hope to be the anchor and + bounce board for all who stay with us.{' '} +

+

Belonging happens at Hilton

+

+ We strive to be familiar but exceed expectations. These assets highlight how + belonging is personified. +

+

We are more than accommodation

+
    +
  • Airport pick up service
  • +
  • Local recommendations
  • +
  • Everyday excursions
  • +
  • Customizable experience
  • +
+
+ + + + Hilton brand email — Q1 campaign 2026 + Market research — hospitality trends 2025 + User research — loyalty programme survey + + +
+ + + Can you help me create a 45-minute presentation, with animations, for an executive + update? + + + + + + + + Desert Sunset + + Edit + Share + Delete + + PNG • 2/3/2024 + + + + + Can you help me create a 45-minute presentation, with animations, for an executive + update? + + + + + + + + Hilton commercial assets + + Edit + Share + Delete + + 2026 + + + +
+ +
+
+ ); +} + +let dummyResponses = [ + "Sure! Here's a summary of the key points based on the assets you shared. The main themes revolve around brand consistency, audience engagement, and clear calls to action across all touchpoints.", + 'Great question. Based on the context provided, I recommend focusing on the narrative arc first, then layering in supporting visuals and data to reinforce the core message.', + "I've analyzed the content and identified three main opportunities: improving visual hierarchy, strengthening the headline, and adding a clearer value proposition in the opening section." +]; + +type Message = + | {id: number; type: 'user' | 'system'; content: string} + | {id: number; type: 'status'; status: 'pending' | 'complete'}; + +let initialResponses = [ + {id: 0, type: 'user', content: 'prompt 1'}, + {id: 1, type: 'system', content: dummyResponses[0]}, + {id: 2, type: 'user', content: 'prompt 2'}, + {id: 3, type: 'system', content: dummyResponses[1]}, + {id: 4, type: 'user', content: 'prompt 3'}, + {id: 5, type: 'system', content: dummyResponses[2]}, + {id: 6, type: 'user', content: 'prompt 4'}, + {id: 7, type: 'system', content: dummyResponses[0]}, + {id: 8, type: 'user', content: 'prompt 5'}, + {id: 9, type: 'system', content: dummyResponses[1]}, + {id: 10, type: 'user', content: 'prompt 6'}, + {id: 11, type: 'system', content: dummyResponses[2]} +] as Message[]; + +export function DynamicThread() { + let [messages, setMessages] = useState(initialResponses); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; + + // TODO: test announcements here since we aren't setting isStreaming here + // maybe the items should announce on mount, but not for initial mount of the whole chat + function handleSend(text: string) { + if (!text.trim()) { + return; + } + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: text}, + {id: nextId.current++, type: 'status', status: 'pending'} + ]); + setTimeout(() => { + let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; + setMessages(prev => [ + ...prev.slice(0, -1), + {id: nextId.current++, type: 'system', content: response} + ]); + }, 1500); + } + + return ( +
+ +
+
+ + + + + +
+ + {msg => { + if (msg.type === 'user') { + return {msg.content}; + } + if (msg.type === 'status') { + return ; + } + return ( + +
+

{msg.content}

+
+ +
+ ); + }} +
+
+ +
+
+ ); +} + +export function VirtualizedThread() { + let [messages, setMessages] = useState(initialResponses); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; + function handleSend(text: string) { + if (!text.trim()) { + return; + } + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: text}, + {id: nextId.current++, type: 'status', status: 'pending'} + ]); + setTimeout(() => { + let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; + setMessages(prev => [ + ...prev.slice(0, -1), + {id: nextId.current++, type: 'system', content: response} + ]); + }, 1500); + } + + return ( +
+ {/* TODO: move this Virtualizer into the Thread component eventually when we get column reverse support */} + + + {/* TODO style these so that they don't become full width in a virtualizer (or at least dont appear visually to be full width) */} + {msg => { + if (msg.type === 'user') { + return {msg.content}; + } + if (msg.type === 'status') { + return ; + } + return ( + +
+

{msg.content}

+
+ +
+ ); + }} +
+
+ +
+ ); +} + +type StreamingMessage = + | {id: number; type: 'user'; content: string} + | {id: number; type: 'system'; content: string; isStreaming?: boolean} + | {id: number; type: 'tool-call'; label: string; isStreaming: boolean} + | {id: number; type: 'sources'; items: string[]} + | {id: number; type: 'card'; title: string; description: string; imageUrl: string} + | {id: number; type: 'status'; status: 'pending' | 'complete'; thinking?: string}; + +let MOCK_SOURCES = [ + 'Hilton brand email — Q1 campaign 2026', + 'Market research — hospitality trends 2025', + 'User research — loyalty programme survey' +]; + +let MOCK_CARD = { + title: 'Desert Sunset', + description: 'PNG • 2/3/2024', + imageUrl: + 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' +}; + +function ToolCallStatus({label, isStreaming}: {label: string; isStreaming: boolean}) { + let textValue = isStreaming ? `${label}…` : `${label} complete`; + return ( + + {isStreaming ? ( + + ) : ( + + )} + {textValue} + + ); +} + +function SourcesMessage({items: sourceItems}: {items: string[]}) { + let textValue = `Found ${sourceItems.length} source${sourceItems.length !== 1 ? 's' : ''}`; + return ( + + + + {sourceItems.map((s, i) => ( + + {s} + + ))} + + + + ); +} + +function CardMessage({ + title, + description, + imageUrl +}: { + title: string; + description: string; + imageUrl: string; +}) { + return ( + + + + + + + {title} + + Edit + Share + Delete + + {description} + + + + ); +} + +export function StreamingThread() { + let [messages, setMessages] = useState( + initialResponses as StreamingMessage[] + ); + let nextId = useRef(initialResponses.length); + let lastMessage = messages.at(-1); + let isDisabled = + lastMessage?.type === 'status' || + lastMessage?.type === 'tool-call' || + (lastMessage?.type === 'system' && lastMessage.isStreaming); + + function handleSend(text: string) { + if (!text.trim()) { + return; + } + + // user message added first so its announcement plays before + setMessages(prev => [...prev, {id: nextId.current++, type: 'user', content: text}]); + + function addTool(label: string, replaceStatus = false) { + setMessages(prev => + replaceStatus + ? [ + ...prev.slice(0, -1), + {id: nextId.current++, type: 'tool-call', label, isStreaming: true} + ] + : [...prev, {id: nextId.current++, type: 'tool-call', label, isStreaming: true}] + ); + } + + function completeTool() { + setMessages(prev => + prev.map(m => (m.type === 'tool-call' && m.isStreaming ? {...m, isStreaming: false} : m)) + ); + } + + function streamText(content: string) { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = content.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + setTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? {...m, content: accumulated, isStreaming: i < tokens.length - 1} + : m + ) + ); + }, i * 80); + }); + } + + // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be + // faster and thus the announcements will get cut off even with polite... + // first batch, does took calls with text response + let timestamp = 0; + let toolCallDuration = 4000; + // Status added after short delay so user message announcement plays first + setTimeout( + () => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'status', status: 'pending'}]); + }, + (timestamp += 1000) + ); + setTimeout(() => addTool('Thinking', true), (timestamp += 1000)); + setTimeout(() => completeTool(), (timestamp += toolCallDuration)); + setTimeout(() => addTool('Loading tool'), (timestamp += 1000)); + setTimeout(() => completeTool(), (timestamp += toolCallDuration)); + setTimeout(() => addTool('Searching'), (timestamp += 1000)); + setTimeout(() => completeTool(), (timestamp += toolCallDuration)); + setTimeout( + () => + streamText( + 'I found some relevant assets that match your request. Let me pull up the details.' + ), + (timestamp += 1000) + ); + + // then does searching, streaming more text, returning a card and sources + setTimeout(() => addTool('Searching'), (timestamp += 4000)); + setTimeout(() => completeTool(), (timestamp += toolCallDuration)); + setTimeout(() => addTool('Querying database'), (timestamp += 1000)); + setTimeout(() => completeTool(), (timestamp += toolCallDuration)); + setTimeout( + () => + setMessages(prev => [...prev, {id: nextId.current++, type: 'status', status: 'pending'}]), + (timestamp += 500) + ); + setTimeout( + () => + setMessages(prev => [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + status: 'complete', + thinking: + 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' + } + ]), + (timestamp += 2000) + ); + setTimeout( + () => + streamText( + 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + + 'layering in supporting visuals and data to reinforce the core message. The main themes ' + + 'revolve around brand consistency, audience engagement, and clear calls to action.' + ), + (timestamp += 1000) + ); + + let streamEndTimestamp = timestamp + 8000; + setTimeout(() => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); + }, streamEndTimestamp); + setTimeout(() => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'sources', items: MOCK_SOURCES}]); + }, streamEndTimestamp + 1000); + } + + return ( + // TODO: these extra div wrappers would need to be implemented by the RAC user, maybe we can internalize some more? + // of particular note is the scroll button. Same for the other styles +
+ +
+
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return {msg.content}; + } + if (msg.type === 'status') { + return ; + } + if (msg.type === 'tool-call') { + return ; + } + if (msg.type === 'sources') { + return ; + } + if (msg.type === 'card') { + return ( + + ); + } + return ( + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
+ ); + }} +
+
+ +
+
+ ); +} + +// TODO: all of the below was copied from rsp-prototypes, just filler for now +// some modifications for streaming and what not +function PromptField({ + onSend, + isDisabled +}: { + onSend?: (text: string) => void; + isDisabled?: boolean; +}) { + let [text, setText] = useState(''); + let [attachments, setAttachments] = useState([ + { + image: 'https://react-spectrum.adobe.com/preview.c3b340d3.png', + title: 'Hilton assets', + description: '2026' + } + ]); + + // Not using RAC DropZone because it adds its own focusable button, + // and we want to avoid an extra tab stop by attaching to the input. + // TODO: support clipboard too (without messing up pasting text) + let inputRef = useRef(null); + let {dropProps, dropButtonProps, isDropTarget} = useDrop({ + ref: inputRef, + hasDropButton: true, + async onDrop(e) { + let files = await Promise.all( + e.items.filter(isFileDropItem).map(async item => ({ + image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '', + title: item.name, + description: item.type + })) + ); + setAttachments(attachments => [...attachments, ...files]); + } + }); + + return ( +
+ + style({ + ...focusRing(), + padding: 16, + boxShadow: 'emphasized', + backgroundColor: { + default: 'elevated', + isDropTarget: 'blue-200' + }, + borderRadius: 'lg', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isFocusWithin: 'gray-900', + isDropTarget: 'blue-800' + } + })({...renderProps, isDropTarget}) + }> + + {attachments.map((attachment, i) => ( + { + setAttachments(attachments.slice(0, i).concat(attachments.slice(i + 1))); + }} + /> + ))} + + setText(value)} slot="prompt"> + +