diff --git a/packages/@react-spectrum/ai/intl/ar-AE.json b/packages/@react-spectrum/ai/intl/ar-AE.json new file mode 100644 index 00000000000..adc6cbede25 --- /dev/null +++ b/packages/@react-spectrum/ai/intl/ar-AE.json @@ -0,0 +1,6 @@ +{ + "chat.newMessage": "New message", + "messagefeedback.thumbDown": "Bad response", + "messagefeedback.thumbUp": "Good response", + "responsestatus.loading": "Loading" +} diff --git a/packages/@react-spectrum/ai/intl/en-US.json b/packages/@react-spectrum/ai/intl/en-US.json index 8ac525f626b..adc6cbede25 100644 --- a/packages/@react-spectrum/ai/intl/en-US.json +++ b/packages/@react-spectrum/ai/intl/en-US.json @@ -1,4 +1,5 @@ { + "chat.newMessage": "New message", "messagefeedback.thumbDown": "Bad response", "messagefeedback.thumbUp": "Good response", "responsestatus.loading": "Loading" diff --git a/packages/@react-spectrum/ai/src/Thread.tsx b/packages/@react-spectrum/ai/src/Chat.tsx similarity index 61% rename from packages/@react-spectrum/ai/src/Thread.tsx rename to packages/@react-spectrum/ai/src/Chat.tsx index f2beefb9c81..b50d071300e 100644 --- a/packages/@react-spectrum/ai/src/Thread.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -16,6 +16,7 @@ import { createContext, forwardRef, ReactNode, + RefObject, useCallback, useContext, useEffect, @@ -31,21 +32,50 @@ import { GridListItemProps, GridListProps } from 'react-aria-components/GridList'; -import {nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; -import {TextFieldContext} from 'react-aria-components/TextField'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; +import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; +import {useFocusWithin} from 'react-aria/useFocusWithin'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; + +const scrollButtonWrapper = style({ + opacity: { + isEntering: 0, + isExiting: 0 + }, + translateY: { + isEntering: 4, + isExiting: 4 + }, + transition: '[opacity, translate]', + transitionDuration: 200, + transitionTimingFunction: { + isExiting: 'in' + }, + pointerEvents: { + isExiting: 'none' + } +}); + +export interface PromptFocusContextValue { + onFocusChange: (isFocused: boolean) => void; +} + +export const PromptFocusContext = createContext({ + onFocusChange: () => {} +}); -interface InternalThreadContextValue { +interface InternalChatContextValue { announceItem: (text: string) => void; - setGridListFocused: (isFocused: boolean) => void; setIsNearBottom: (isNear: boolean) => void; setScrollElement: (element: HTMLElement | null) => void; } -const InternalThreadContext = createContext({ +const InternalChatContext = createContext({ announceItem: text => announce(text, 'polite'), - setGridListFocused: () => {}, setIsNearBottom: () => {}, setScrollElement: () => {} }); @@ -61,64 +91,83 @@ const ThreadScrollButtonContext = createContext( }); // TODO: make this more RAC like (aka default class name and other RAC prop) -interface ThreadProps { +interface ChatProps { className?: string; style?: CSSProperties; children?: ReactNode; } -// TODO: 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 -export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thread( - props: ThreadProps, +export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( + props: ChatProps, ref: DOMRef ) { let {children, className, style} = props; let domRef = useDOMRef(ref); - let isGridListFocusedRef = useRef(false); let isFieldFocusedRef = useRef(false); + let isChatFocusWithinRef = useRef(false); let hasNewMessagesRef = useRef(false); let timeout = useRef | null>(null); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/ai'); let scrollRef = useRef(null); let scrollToBottom = useCallback(() => { - scrollRef.current?.scrollTo({top: 0, behavior: 'smooth'}); + let el = scrollRef.current; + if (!el) { + return; + } + // TODO: will need some kind of api to programatically set the focused item to + // the newest item in the gridlist in the virtualizer case. this works for + // non-virtualized for now though + el.addEventListener( + 'scrollend', + () => { + let firstRow = el.querySelector('[role="row"]'); + (firstRow ?? el).focus(); + }, + {once: true} + ); + el.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); + // are outside the field, only announce there are new responses. If not in chat at all, don't announce + let announceItem = useCallback( + (text: string) => { + if (isFieldFocusedRef.current) { + announce(text, 'polite'); + return; } - return; - } - if (isFieldFocusedRef.current) { - announce(text, 'polite'); - } - }, []); - - let setGridListFocused = useCallback((isFocused: boolean) => { - isGridListFocusedRef.current = isFocused; - }, []); + if (isChatFocusWithinRef.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(stringFormatter.format('chat.newMessage'), '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); + } + } + }, + [stringFormatter] + ); let setScrollElement = useCallback((el: HTMLElement | null) => { scrollRef.current = el; }, []); + let {focusWithinProps} = useFocusWithin({ + onFocusWithinChange: isFocused => { + isChatFocusWithinRef.current = isFocused; + } + }); + useEffect(() => { return () => { if (timeout.current !== null) { @@ -130,26 +179,18 @@ export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thre return ( { - isFieldFocusedRef.current = focused; - } - } + onFocusChange: (focused: boolean) => { + isFieldFocusedRef.current = focused; } } ] ]}> -
+
{children}
@@ -157,22 +198,22 @@ export const Thread = /*#__PURE__*/ (forwardRef as forwardRefType)(function Thre }); // TODO: update the items/className/children/etc type to reflect a thread specific classname once we finalize API -interface ThreadListProps extends Pick< +interface ThreadProps extends Pick< GridListProps, - 'items' | 'children' | 'focusOnEntry' | 'aria-label' | 'aria-labelledby' | 'className' + 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' | 'className' > {} -export function ThreadList(props: ThreadListProps) { +export function Thread(props: ThreadProps) { let { items, children, className, - focusOnEntry, + UNSTABLE_focusOnEntry, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = props; - let {setGridListFocused, setIsNearBottom, setScrollElement} = useContext(InternalThreadContext); + let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); @@ -184,28 +225,6 @@ export function ThreadList(props: ThreadListProps) { [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) { @@ -237,7 +256,7 @@ export function ThreadList(props: ThreadListProps) { disallowTypeAhead onScroll={handleScroll} keyboardNavigationBehavior="tab" - focusOnEntry={focusOnEntry} + UNSTABLE_focusOnEntry={UNSTABLE_focusOnEntry} items={items} aria-label={ariaLabel} aria-labelledby={ariaLabelledby} @@ -257,19 +276,39 @@ interface ThreadScrollButtonProps { // and ditch the wrapper? export function ThreadScrollButton({children}: ThreadScrollButtonProps) { let {isNearBottom, scrollToBottom} = useContext(ThreadScrollButtonContext); + let ref = useRef(null); + let isVisible = !isNearBottom; + let isExiting = useExitAnimation(ref, isVisible); - if (isNearBottom) { + if (!isVisible && !isExiting) { return null; } return ( - {children} + + {children} + ); } +interface ThreadScrollButtonInnerProps { + domRef: RefObject; + isExiting: boolean; + children?: ReactNode; +} + +function ThreadScrollButtonInner({domRef, isExiting, children}: ThreadScrollButtonInnerProps) { + let isEntering = useEnterAnimation(domRef); + return ( +
+ {children} +
+ ); +} + // TODO: update the className type to reflect a thread specific classname once we finalize API interface ThreadItemProps extends Pick { /** Whether or not the item's content is currently being streamed in. */ @@ -280,7 +319,7 @@ interface ThreadItemProps extends Pick { if (prompt.segments.length === 0) { return; @@ -198,7 +203,7 @@ export function PromptField(props: PromptFieldProps) { onAddAttachments, onRemoveAttachments }}> -
+
diff --git a/packages/@react-spectrum/ai/stories/Thread.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx similarity index 96% rename from packages/@react-spectrum/ai/stories/Thread.stories.tsx rename to packages/@react-spectrum/ai/stories/Chat.stories.tsx index 38fbb6669a5..e04ded54106 100644 --- a/packages/@react-spectrum/ai/stories/Thread.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -13,6 +13,7 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; +import {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Content} from '@react-spectrum/s2/Content'; import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -28,18 +29,17 @@ import {PromptField, PromptFieldSubmitButton, PromptTokenField} from '../src/Pro import {ReactNode, useRef, useState} from 'react'; import {ResponseStatus, ResponseStatusPanel, ResponseStatusTitle} from '../src/ResponseStatus'; import {Text} from '@react-spectrum/s2/Text'; -import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; import type {TokenSegmentList} from '../src/TokenSegmentList'; import {UserMessage} from '../src/UserMessage'; import {Virtualizer} from 'react-aria-components/Virtualizer'; -const meta: Meta = { - component: Thread, +const meta: Meta = { + component: Chat, parameters: { layout: 'centered' }, tags: ['autodocs'], - title: 'AI/Thread', + title: 'AI/Chat', decorators: [ Story => (
@@ -137,7 +137,7 @@ function CardMessage({ ); } -export function StreamingThread() { +export function StreamingChat() { let [messages, setMessages] = useState( initialResponses as StreamingMessage[] ); @@ -352,7 +352,7 @@ export function StreamingThread() { gap: 32, height: '100%' })}> -
- {(msg: StreamingMessage) => { if (msg.type === 'user') { @@ -401,7 +400,12 @@ export function StreamingThread() { return ( + className={style({ + ...focusRing(), + borderRadius: 'default', + display: 'flex', + justifyContent: 'end' + })}> {msg.content} ); @@ -466,7 +470,7 @@ export function StreamingThread() { ); }} - +
- +
); } // Ignore this story, just here for local testing -export function VirtualizedThread() { +export function VirtualizedChat() { let [messages, setMessages] = useState(initialResponses); let nextId = useRef(initialResponses.length); let lastMessage = messages.at(-1); @@ -520,7 +524,7 @@ export function VirtualizedThread() { + className={style({ + ...focusRing(), + borderRadius: 'lg', + display: 'flex', + justifyContent: 'end' + })}> {msg.content} ); diff --git a/packages/@react-spectrum/ai/test/Thread.browser.test.tsx b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx similarity index 89% rename from packages/@react-spectrum/ai/test/Thread.browser.test.tsx rename to packages/@react-spectrum/ai/test/Chat.browser.test.tsx index baf016a7ac3..89e2a05735f 100644 --- a/packages/@react-spectrum/ai/test/Thread.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ +import {Chat, Thread, ThreadItem} from '../src/Chat'; import {describe, expect, it} from 'vitest'; import React from 'react'; import {render} from 'vitest-browser-react'; -import {Thread, ThreadItem, ThreadList} from '../src/Thread'; import {userEvent} from 'vitest/browser'; interface Message { @@ -32,11 +32,11 @@ describe('Thread browser', () => { ]; let {container} = await render( - - + + {(item: Message) => {item.text}} - - + + ); let gridlist = container.querySelector('[role=grid]') as HTMLElement; diff --git a/packages/@react-spectrum/ai/test/Thread.test.tsx b/packages/@react-spectrum/ai/test/Chat.test.tsx similarity index 91% rename from packages/@react-spectrum/ai/test/Thread.test.tsx rename to packages/@react-spectrum/ai/test/Chat.test.tsx index aa265b7fb63..98bfa0e312e 100644 --- a/packages/@react-spectrum/ai/test/Thread.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.test.tsx @@ -14,9 +14,10 @@ jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; -import {Button, Input, TextField} from 'react-aria-components'; +import {Button} from 'react-aria-components'; +import {Chat, Thread, ThreadItem, ThreadScrollButton} from '../src/Chat'; +import {PromptField, PromptTokenField} from '../src/PromptField'; import React from 'react'; -import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; import userEvent from '@testing-library/user-event'; interface Message { @@ -27,33 +28,33 @@ interface Message { function TestThread({ messages, - focusOnEntry + UNSTABLE_focusOnEntry }: { messages: Message[]; - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusOnEntry?: 'first' | 'last'; }) { return ( - + - + {(item: Message) => ( {item.text} )} - - - - - + + + + + ); } let mockAnnounce = announce as jest.MockedFunction; -describe('Thread', () => { +describe('Chat', () => { let user; beforeAll(() => { @@ -245,10 +246,10 @@ describe('Thread', () => { }); describe('focus behavior', () => { - it('focuses the first item in the list when tabbing in if focusOnEntry="first"', async () => { + it('focuses the first item in the list when tabbing in if UNSTABLE_focusOnEntry="first"', async () => { let {getByRole} = render( { expect(document.activeElement).toBe(rows[0]); }); - it('focuses the last item in the list when tabbing in if focusOnEntry="last"', async () => { + it('focuses the last item in the list when tabbing in if UNSTABLE_focusOnEntry="last"', async () => { let {getByRole} = render( * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusOnEntry?: 'first' | 'last'; } export const GridListContext = @@ -242,9 +242,14 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { [props, ref] = useContextProps(props, ref, SelectableCollectionContext); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {shouldUseVirtualFocus, filter, disallowTypeAhead, focusOnEntry, ...DOMCollectionProps} = - props; + let { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldUseVirtualFocus, + filter, + disallowTypeAhead, + UNSTABLE_focusOnEntry, + ...DOMCollectionProps + } = props; let { dragAndDropHooks, keyboardNavigationBehavior = 'arrow', @@ -303,7 +308,7 @@ function GridListInner({props, collection, gridListRef: ref}: GridListInnerPr isVirtualized, shouldSelectOnPressUp: props.shouldSelectOnPressUp, disallowTypeAhead, - focusOnEntry + UNSTABLE_focusOnEntry }, filteredState, ref diff --git a/packages/react-aria/src/gridlist/useGridList.ts b/packages/react-aria/src/gridlist/useGridList.ts index 7e37629560f..6d5ba3fb0a9 100644 --- a/packages/react-aria/src/gridlist/useGridList.ts +++ b/packages/react-aria/src/gridlist/useGridList.ts @@ -112,7 +112,7 @@ export interface AriaGridListOptions extends Omit, 'chil * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusOnEntry?: 'first' | 'last'; } export interface GridListAria { @@ -164,7 +164,7 @@ export function useGridList( disallowTypeAhead, autoFocus: props.autoFocus, escapeKeyBehavior, - focusOnEntry: props.focusOnEntry + UNSTABLE_focusOnEntry: props.UNSTABLE_focusOnEntry }); let id = useId(props.id); diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 98045e6c3e6..a8599907985 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -132,7 +132,7 @@ export interface AriaSelectableCollectionOptions { * * @private */ - focusOnEntry?: 'first' | 'last'; + UNSTABLE_focusOnEntry?: 'first' | 'last'; } export interface SelectableCollectionAria { @@ -162,7 +162,7 @@ export function useSelectableCollection( // If no scrollRef is provided, assume the collection ref is the scrollable region scrollRef = ref, linkBehavior = 'action', - focusOnEntry + UNSTABLE_focusOnEntry } = options; let {direction} = useLocale(); let router = useRouter(); @@ -291,7 +291,7 @@ export function useSelectableCollection( if (manager.focusedKey === null && e.shiftKey) { return; } - // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) + e.preventDefault(); let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -431,12 +431,14 @@ export function useSelectableCollection( } }; - // we need the "virtual" modality case checks here because shift tabbing from the prompt field's asset card back into the + // we need the "virtual" modality case checks here because shift tabbing from the prompt field's attachment card back into the // thread is a virtual focus event (the tab handler in onKeyDown focuses the ref of the AttachementList aka TagGroup via a focus() call, hence the virtual modality) - if (focusOnEntry && (modality === 'keyboard' || modality === 'virtual')) { + if (UNSTABLE_focusOnEntry && (modality === 'keyboard' || modality === 'virtual')) { // always go to the first item in the Thread when tabbing forwards/backwards into the collection // since it is probably more important to the user to see the new prompt reply rather than go to the last focused key - navigateToKey(focusOnEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.()); + navigateToKey( + UNSTABLE_focusOnEntry === 'first' ? delegate.getFirstKey?.() : delegate.getLastKey?.() + ); } else if (manager.focusedKey == null) { // If the user hasn't yet interacted with the collection, there will be no focusedKey set. // Attempt to detect whether the user is tabbing forward or backward into the collection @@ -465,7 +467,7 @@ export function useSelectableCollection( focusWithoutScrolling(element); } - if (modality === 'keyboard' || modality === 'virtual') { + if (modality === 'keyboard' || (UNSTABLE_focusOnEntry && modality === 'virtual')) { scrollIntoViewport(element, {containingElement: ref.current}); } } diff --git a/packages/react-aria/src/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index 00b0b54b28c..bedd41b674a 100644 --- a/packages/react-aria/src/toolbar/useToolbar.ts +++ b/packages/react-aria/src/toolbar/useToolbar.ts @@ -89,7 +89,6 @@ export function useToolbar( // out of the entire toolbar. To do this, move focus // to the first or last focusable child, and let the // browser handle the Tab key as usual from there. - e.stopPropagation(); lastFocused.current = getActiveElement() as HTMLElement; if (e.shiftKey) { focusManager.focusFirst();