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 85% rename from packages/@react-spectrum/ai/test/Thread.browser.test.tsx rename to packages/@react-spectrum/ai/test/Chat.browser.test.tsx index baf016a7ac3..23154add82c 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 { @@ -21,7 +21,8 @@ interface Message { text: string; } -describe('Thread browser', () => { +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; +describeOrSkip('Chat browser', () => { describe('spatial navigation', () => { // This test is flaky in Firefox. Skipping for now. it.skip('navigates between items in spatial order via arrow keys', async () => { @@ -32,11 +33,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..0eb80668e2a 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', () => { +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; +describeOrSkip('Thread', () => { 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( | 'selectionBehavior' | 'onScroll' | 'onCellAction' + | 'keyboardNavigationBehavior' | keyof GlobalDOMAttributes >, UnsafeStyles, diff --git a/packages/@react-spectrum/s2/stories/CardView.stories.tsx b/packages/@react-spectrum/s2/stories/CardView.stories.tsx index e5efd0212a4..2f00aba5d14 100644 --- a/packages/@react-spectrum/s2/stories/CardView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/CardView.stories.tsx @@ -27,6 +27,7 @@ import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import {SkeletonCollection} from '../src/SkeletonCollection'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {TextField} from '../src/TextField'; import {useAsyncList} from 'react-stately/useAsyncList'; const meta: Meta = { @@ -72,7 +73,15 @@ const avatarSize = { XL: 32 } as const; -export function PhotoCard({item, layout}: {item: Item; layout: string}) { +export function PhotoCard({ + item, + layout, + interactive +}: { + item: Item; + layout: string; + interactive?: React.ReactNode; +}) { return ( {({size}) => ( @@ -112,12 +121,20 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) {
- - {item.user.name} +
+ + {item.user.name} +
+ {interactive}
@@ -126,7 +143,7 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) { ); } -export const ExampleRender = (args: CardViewProps) => { +export const ExampleRender = (args: CardViewProps & {interactive?: React.ReactNode}) => { let list = useAsyncList({ async load({signal, cursor, items}) { let page = cursor || 1; @@ -155,7 +172,9 @@ export const ExampleRender = (args: CardViewProps) => { onLoadMore={args.loadingState === 'idle' ? list.loadMore : undefined} styles={cardViewStyles}> - {item => } + {item => ( + + )} {(loadingState === 'loading' || loadingState === 'loadingMore') && ( @@ -288,3 +307,14 @@ export const CollectionCards: Story = { onAction: undefined } }; + +export const CardViewWithTextField: Story = { + render: args => ( + } /> + ), + args: { + loadingState: 'idle', + onAction: undefined, + selectionMode: 'multiple' + } +}; diff --git a/packages/dev/s2-docs/package.json b/packages/dev/s2-docs/package.json index af2b357bd21..5470e7669d1 100644 --- a/packages/dev/s2-docs/package.json +++ b/packages/dev/s2-docs/package.json @@ -44,6 +44,7 @@ "remark-stringify": "^11.0.0", "satori": "^0.16.1", "sharp": "^0.33.5", + "textarea-caret": "^3.1.0", "tree-sitter-highlight": "^1.0.1", "ts-morph": "^26.0.0", "unified": "^11.0.5", diff --git a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx index 2400f9026bd..290e3affd82 100644 --- a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx @@ -1103,6 +1103,137 @@ function AsyncLoadingExample() { } ``` +## Inline completions + +Set the Autocomplete `inputValue` to a substring of the full input value to enable inline completions such as @mentions. Completions can be displayed in a popover by controlling its `isOpen` state. For inline suggestions, use the `getTargetRect` prop to position the popover relative to the anchor character. + +```tsx render +"use client"; +import {Autocomplete, useFilter} from 'react-aria-components/Autocomplete'; +import {TextArea} from 'vanilla-starter/TextField'; +import {Popover} from 'vanilla-starter/Popover'; +import {Menu, MenuItem} from 'vanilla-starter/Menu'; +import {useState, useRef} from 'react'; +import {flushSync} from 'react-dom'; +import getCaretRect from 'textarea-caret'; + +/*- begin collapse -*/ +const usernames = [ + 'alexmiller', + 'sarahjones', + 'davidkim', + 'emmawatson', + 'oliverliu', + 'ellagreen', + 'lucasbrown', + 'amandarivera', + 'masonlee', + 'nataliasmith', + 'benjamintaylor', + 'zoewilson', + 'henrywalker', + 'madelineyoung', + 'noahscott', + 'lucygonzalez', + 'jacobmartin', + 'averymoore', + 'loganmurphy', + 'miahernandez', + 'danieladair', + 'sofiacox', + 'jackharris', + 'chloebaker', + 'liamrodriguez' +]; +/*- end collapse -*/ + +function Example() { + let {startsWith} = useFilter({sensitivity: 'base'}); + let [inputValue, setInputValue] = useState(''); + let [anchorIndex, setAnchorIndex] = useState(-1); + let [filterValue, setFilterValue] = useState(''); + let inputRef = useRef(null); + + let updateFilter = () => { + let {selectionStart, selectionEnd, value} = inputRef.current!; + if (selectionStart === selectionEnd && document.activeElement === inputRef.current!) { + // The current filter value is the substring between + // the anchor character '@' and the caret position. + let index = value.lastIndexOf('@', selectionStart); + if (index >= 0) { + let slice = value.slice(index + 1, selectionStart); + // Spaces are not allowed in the filter value. + if (!slice.includes(' ')) { + setAnchorIndex(index); + setFilterValue(slice); + return; + } + } + } + + // Reset the anchor index, but not the filter value so + // that the menu does not flicker during the close animation. + setAnchorIndex(-1); + }; + + return ( + /*- begin highlight -*/ + // Pass the filter substring to Autocomplete. + + {/*- end highlight -*/} +