diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx index 162837b1ba4..2b65cf296e0 100644 --- a/packages/@react-spectrum/ai/src/Chat.tsx +++ b/packages/@react-spectrum/ai/src/Chat.tsx @@ -20,6 +20,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState } from 'react'; @@ -34,12 +35,14 @@ import { } from 'react-aria-components/GridList'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {ListLayout} from 'react-stately/useVirtualizerState'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; 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'; +import {Virtualizer} from 'react-aria-components/Virtualizer'; const scrollButtonWrapper = style({ opacity: { @@ -131,7 +134,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( }, {once: true} ); - el.scrollTo({top: 0, behavior: 'smooth'}); + el.scrollTo({top: el.scrollHeight - el.clientHeight, behavior: 'smooth'}); }, []); let [isNearBottom, setIsNearBottom] = useState(true); @@ -202,6 +205,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( ); }); +// TODO: do we want UNSTABLE_focusOnEntry on ThreadProps or do we just want to set it on Thread for users so they don't have to do it themselves? export interface ThreadProps extends Pick< GridListProps, 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' @@ -210,6 +214,15 @@ export interface ThreadProps extends Pick< * Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; + /** + * The maximum distance in px from the bottom of the content for the + * viewport to be considered "near the end". While near the end, appended content and streaming + * size changes will keep the viewport pinned to the latest output. + * + * @default 100 + */ + scrollEndThreshold?: number; + anchorTo?: 'end'; } export function Thread(props: ThreadProps) { @@ -218,14 +231,17 @@ export function Thread(props: ThreadProps) { children, styles, UNSTABLE_focusOnEntry, + anchorTo, + scrollEndThreshold = 100, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = props; + let reversedItems = useMemo(() => (items != null ? [...items].reverse() : undefined), [items]); + let {setIsNearBottom, setScrollElement} = useContext(InternalChatContext); let isNearBottomRef = useRef(true); let gridListRef = useRef(null); - let callbackRef = useCallback( (el: HTMLDivElement | null) => { gridListRef.current = el; @@ -240,46 +256,71 @@ export function Thread(props: ThreadProps) { return; } - // because column reversed scrollTop=0 is the bottom and the scrollTop goes negative as you move up - let nearBottom = el.scrollTop > -100; + let nearBottom = + anchorTo === 'end' + ? el.scrollTop >= el.scrollHeight - el.clientHeight - scrollEndThreshold + : 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 - if (isNearBottomRef.current) { - requestAnimationFrame(() => { - if (gridListRef.current) { - gridListRef.current.scrollTop = 0; - } - }); - } - }, [items]); + }, [setIsNearBottom, scrollEndThreshold, anchorTo]); - return ( + let gridList = ( {children} ); + + if (anchorTo === 'end') { + return ( + + + {children} + + + ); + } + + return gridList; } export interface ThreadScrollButtonProps { @@ -328,7 +369,7 @@ const threadItemBase = style({ borderRadius: 'default' }); -export interface ThreadItemProps extends Pick { +export interface ThreadItemProps extends Pick { /** * Spectrum-defined styles, returned by the `style()` macro. */ @@ -340,7 +381,7 @@ export interface ThreadItemProps extends Pick mergeStyles(threadItemBase({...renderProps}), styles)}> {children} diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index ae2c03cd45a..e205b76f632 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -16,9 +16,7 @@ import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; import {Chat} from '../src/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Content} from '@react-spectrum/s2/Content'; -import {GridList} from 'react-aria-components'; import {Image} from '@react-spectrum/s2/Image'; -import {ListLayout} from 'react-stately/useVirtualizerState'; import {MenuItem} from '@react-spectrum/s2/Menu'; import { MessageFeedback, @@ -43,7 +41,6 @@ import type {Meta} from '@storybook/react'; import {ReactNode, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; const meta: Meta = { component: Chat, @@ -54,7 +51,7 @@ const meta: Meta = { title: 'AI/Chat', decorators: [ Story => ( -
+
) @@ -63,12 +60,6 @@ const meta: Meta = { export default meta; -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'}; @@ -383,18 +374,13 @@ export function StreamingChat() { ]), (timestamp += 1000) ); - addTimeout( - () => - 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.', - MOCK_SOURCES - ), - (timestamp += 500) - ); + let secondStreamContent = + '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.'; + addTimeout(() => streamText(secondStreamContent, MOCK_SOURCES), (timestamp += 500)); - let streamEndTimestamp = timestamp + 500; + let streamEndTimestamp = timestamp + (secondStreamContent.split(' ').length - 1) * 80 + 500; addTimeout(() => { setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); }, streamEndTimestamp); @@ -459,7 +445,7 @@ export function StreamingChat() {
(initialResponses); +export function VirtualizedStreamingChat() { + let [messages, setMessages] = useState( + initialResponses as StreamingMessage[] + ); let nextId = useRef(initialResponses.length); - let lastMessage = messages.at(-1); - let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + function handleSend(prompt: TokenSegmentList) { + setGenerating(true); + // user message added first so its announcement plays before setMessages(prev => [ ...prev, - {id: nextId.current++, type: 'user', content: prompt.toString()}, - {id: nextId.current++, type: 'status', status: 'pending'} + {id: nextId.current++, type: 'user', content: prompt.toString()} ]); - setTimeout(() => { - let response = dummyResponses[Math.floor(Math.random() * dummyResponses.length)]; + + function addTool(label: string, replaceStatus = false) { + setMessages(prev => + replaceStatus + ? [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + : [ + ...prev, + { + id: nextId.current++, + type: 'status', + label, + isStreaming: true, + details: '' + } + ] + ); + } + + function completeTool(details: string) { + setMessages(prev => + prev.map(m => + m.type === 'status' && m.isStreaming ? {...m, isStreaming: false, details} : m + ) + ); + } + + function streamText(content: string, sources?: string[]) { setMessages(prev => [ - ...prev.slice(0, -1), - {id: nextId.current++, type: 'system', content: response} + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} ]); - }, 1500); + let tokens = content.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + setTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? { + ...m, + content: accumulated, + isStreaming: !isLastToken, + ...(isLastToken && sources ? {sources} : {}) + } + : m + ) + ); + }, i * 80); + }); + } + + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + // 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 tool calls with text response + let timestamp = 0; + let toolCallDuration = 1000; + // Status added after short delay so user message announcement plays first + addTimeout( + () => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]); + }, + (timestamp += 500) + ); + addTimeout(() => addTool('Thinking', true), (timestamp += 500)); + addTimeout( + () => + completeTool( + 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' + ), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Loading tool'), (timestamp += 500)); + addTimeout( + () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Searching'), (timestamp += 500)); + addTimeout( + () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), + (timestamp += toolCallDuration) + ); + addTimeout( + () => + streamText( + 'I found some relevant assets that match your request. Let me pull up the details.' + ), + (timestamp += 500) + ); + + // then does searching, streaming more text, returning a card and sources + addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout( + () => + completeTool('Identified additional brand materials related to the presentation context.'), + (timestamp += toolCallDuration) + ); + addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout( + () => + completeTool( + 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' + ), + (timestamp += toolCallDuration) + ); + addTimeout( + () => + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'status', + label: 'Generating response', + isStreaming: true, + details: '' + } + ]), + (timestamp += 500) + ); + addTimeout( + () => + setMessages(prev => [ + ...prev.slice(0, -1), + { + id: nextId.current++, + type: 'status', + label: 'Response generated', + isStreaming: false, + details: + '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 += 1000) + ); + let secondStreamContent = + '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.'; + addTimeout(() => streamText(secondStreamContent, MOCK_SOURCES), (timestamp += 500)); + + let streamEndTimestamp = timestamp + (secondStreamContent.split(' ').length - 1) * 80 + 500; + addTimeout(() => { + setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); + }, streamEndTimestamp); + addTimeout(() => { + setMessages(prev => [ + ...prev, + { + id: nextId.current++, + type: 'suggestions', + title: 'Suggested follow-ups', + suggestions: MOCK_SUGGESTIONS + } + ]); + setGenerating(false); + }, 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 => { - if (msg.type === 'user') { +
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + // TODO: probably want ThreadItem to be a part of UserMessage? + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; + let title = msg.isStreaming ? `${msg.label}…` : msg.label; + // TODO: might want to have ThreadItem be a part of the ResponseStatus by default? + // Ideally it would auto focus the ResponseStatus itself via focusMode=child, but we + // probably want to make that on a case by case basis + // (aka it would make sense to auto focus children here but not for a system message that has text and other focusable children) + return ( + + + {title} + + {msg.details && ( +

{msg.details}

+ )} +
+
+
+ ); + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well + // but this one I could see perhaps being a standalone component to be used outside of thread + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } return ( - - {msg.content} - + isStreaming={msg.isStreaming} + sources={msg.sources}> +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } + ); - } - if (msg.type === 'status') { - let isPending = msg.status === 'pending'; - let message = isPending ? 'Generating response' : 'Response generated'; + }} +
+
+ { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
+ + +
+
+ +
+ ); +} + +let DUMMY_RESPONSES = [ + "That's a great question! I'm here to help. Could you give me a bit more context so I can provide a more tailored response?", + 'Sure! Here is a quick summary: the key points are clarity, brevity, and relevance. Let me know if you want me to expand on any of these.', + "Interesting topic. Here's what I know: this area has been evolving rapidly, and there are a few different perspectives worth considering. Want me to dive deeper?", + "I've processed your message. Based on what you've shared, I'd suggest starting with a clear goal, then breaking it into smaller actionable steps. Does that help?", + 'Great point! I think the best approach here depends on your specific situation. Can you tell me more about your constraints or priorities?' +]; + +export function EmptyChat() { + let [messages, setMessages] = useState([]); + let nextId = useRef(0); + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + + function handleSend(prompt: TokenSegmentList) { + setGenerating(true); + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: prompt.toString()} + ]); + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + + let response = DUMMY_RESPONSES[Math.floor(Math.random() * DUMMY_RESPONSES.length)]; + + addTimeout(() => { + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'system', content: '', isStreaming: true} + ]); + let tokens = response.split(' '); + let accumulated = ''; + tokens.forEach((token, i) => { + addTimeout(() => { + accumulated += (i === 0 ? '' : ' ') + token; + let isLastToken = i === tokens.length - 1; + setMessages(prev => + prev.map(m => + m.type === 'system' && m.isStreaming + ? {...m, content: accumulated, isStreaming: !isLastToken} + : m + ) + ); + if (isLastToken) { + setGenerating(false); + } + }, i * 60); + }); + }, 600); + } + + return ( +
+ +
+
+ + + + + +
+ + {(msg: StreamingMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + if (msg.type === 'status') { + let announcement = msg.isStreaming ? `${msg.label}…` : `${msg.label} complete`; + let title = msg.isStreaming ? `${msg.label}…` : msg.label; + return ( + + + {title} + + {msg.details && ( +

{msg.details}

+ )} +
+
+
+ ); + } + if (msg.type === 'card') { + return ( + + ); + } + if (msg.type === 'suggestions') { + return ( + + + {msg.suggestions.map((s, i) => ( + {s} + ))} + + + ); + } return ( - - - {message} - - + +
+

{msg.content || ''}

+
+ {!msg.isStreaming && } +
); - } - return ( - -
-

{msg.content}

-
- -
- ); - }} - - - -
- - + }} +
-
+ { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
+ + +
+
+
); } diff --git a/packages/@react-spectrum/ai/test/Chat.browser.test.tsx b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx index 23154add82c..ab8a447c70d 100644 --- a/packages/@react-spectrum/ai/test/Chat.browser.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.browser.test.tsx @@ -34,7 +34,7 @@ describeOrSkip('Chat browser', () => { let {container} = await render( - + {(item: Message) => {item.text}} diff --git a/packages/@react-spectrum/ai/test/Chat.test.tsx b/packages/@react-spectrum/ai/test/Chat.test.tsx index 0eb80668e2a..8b09a303dc9 100644 --- a/packages/@react-spectrum/ai/test/Chat.test.tsx +++ b/packages/@react-spectrum/ai/test/Chat.test.tsx @@ -261,7 +261,7 @@ describeOrSkip('Thread', () => { let rows = gridlist.querySelectorAll('[role="row"]'); await user.tab(); expect(document.activeElement).toBe(rows[0]); - expect(rows[0]).toHaveTextContent('Hello'); + expect(rows[0]).toHaveTextContent('World'); await user.keyboard('{ArrowDown}'); expect(document.activeElement).toBe(rows[1]); @@ -289,7 +289,7 @@ describeOrSkip('Thread', () => { let rows = gridlist.querySelectorAll('[role="row"]'); await user.tab(); expect(document.activeElement).toBe(rows[1]); - expect(rows[1]).toHaveTextContent('World'); + expect(rows[1]).toHaveTextContent('Hello'); await user.keyboard('{ArrowUp}'); expect(document.activeElement).toBe(rows[0]); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index b870bd94d76..39ed73d4e2a 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -48,15 +48,23 @@ export interface VirtualizerProps { layout: LayoutClass | ILayout; /** Options for the layout. */ layoutOptions?: O; + /** + * Whether to observe each item's size with a ResizeObserver and re-measure when it changes. + */ + shouldObserveItemSize?: boolean; + /** Distance from content bottom (px) within which the viewport is considered "near end". */ + scrollEndThreshold?: number; } -interface LayoutContextValue { +interface VirtualizerOptionsContextValue { layout: ILayout; layoutOptions?: any; + shouldObserveItemSize?: boolean; + scrollEndThreshold?: number; } const VirtualizerContext = createContext | null>(null); -const LayoutContext = createContext(null); +const VirtualizerOptionsContext = createContext(null); /** * A Virtualizer renders a scrollable collection of data using customizable layouts. @@ -64,7 +72,13 @@ const LayoutContext = createContext(null); * them as the user scrolls. */ export function Virtualizer(props: VirtualizerProps): JSX.Element { - let {children, layout: layoutProp, layoutOptions} = props; + let { + children, + layout: layoutProp, + layoutOptions, + shouldObserveItemSize, + scrollEndThreshold + } = props; let layout = useMemo( () => (typeof layoutProp === 'function' ? new layoutProp() : layoutProp), [layoutProp] @@ -84,7 +98,10 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { return ( - {children} + + {children} + ); } @@ -95,7 +112,8 @@ function CollectionRoot({ scrollRef, renderDropIndicator }: CollectionRootProps) { - let {layout, layoutOptions} = useContext(LayoutContext)!; + let {layout, layoutOptions, shouldObserveItemSize, scrollEndThreshold} = + useContext(VirtualizerOptionsContext)!; // oxlint-disable-next-line react/react-compiler let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ @@ -119,7 +137,8 @@ function CollectionRoot({ return {...layoutOptions, ...layoutOptions2}; } return layoutOptions || layoutOptions2; - }, [layoutOptions, layoutOptions2]) + }, [layoutOptions, layoutOptions2]), + scrollEndThreshold }); let {contentProps} = useScrollView( @@ -137,7 +156,7 @@ function CollectionRoot({ return (
- {renderChildren(null, state.visibleViews, renderDropIndicator)} + {renderChildren(null, state.visibleViews, renderDropIndicator, shouldObserveItemSize)}
); @@ -146,28 +165,39 @@ function CollectionRoot({ function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); + let {shouldObserveItemSize} = useContext(VirtualizerOptionsContext)!; + return renderChildren( + parentView, + Array.from(parentView.children), + renderDropIndicator, + shouldObserveItemSize + ); } function renderChildren( parent: View | null, children: View[], - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + shouldObserveItemSize?: boolean ) { - return children.map(view => renderWrapper(parent, view, renderDropIndicator)); + return children.map(view => + renderWrapper(parent, view, renderDropIndicator, shouldObserveItemSize) + ); } function renderWrapper( parent: View | null, reusableView: View, - renderDropIndicator?: (target: ItemDropTarget) => ReactNode + renderDropIndicator?: (target: ItemDropTarget) => ReactNode, + shouldObserveItemSize?: boolean ): ReactNode { let rendered = ( + parent={parent?.layoutInfo} + shouldObserveItemSize={shouldObserveItemSize}> {reusableView.rendered} ); diff --git a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx index c5533cf7b31..81705448919 100644 --- a/packages/react-aria/src/virtualizer/VirtualizerItem.tsx +++ b/packages/react-aria/src/virtualizer/VirtualizerItem.tsx @@ -22,16 +22,18 @@ interface VirtualizerItemProps extends Omit { style?: CSSProperties; className?: string; children: ReactNode; + shouldObserveItemSize?: boolean; } export function VirtualizerItem(props: VirtualizerItemProps): JSX.Element { - let {style, className, layoutInfo, virtualizer, parent, children} = props; + let {style, className, layoutInfo, virtualizer, parent, children, shouldObserveItemSize} = props; let {direction} = useLocale(); let ref = useRef(null); useVirtualizerItem({ layoutInfo, virtualizer, - ref + ref, + shouldObserveItemSize }); return ( diff --git a/packages/react-aria/src/virtualizer/useVirtualizerItem.ts b/packages/react-aria/src/virtualizer/useVirtualizerItem.ts index a76a6e74431..bc61082f8af 100644 --- a/packages/react-aria/src/virtualizer/useVirtualizerItem.ts +++ b/packages/react-aria/src/virtualizer/useVirtualizerItem.ts @@ -12,7 +12,8 @@ import {Key, RefObject} from '@react-types/shared'; import {LayoutInfo, Size} from 'react-stately/useVirtualizerState'; -import {useCallback} from 'react'; +import {useCallback, useEffect} from 'react'; +import {useEffectEvent} from '../utils/useEffectEvent'; import {useLayoutEffect} from '../utils/useLayoutEffect'; interface IVirtualizer { @@ -23,10 +24,11 @@ export interface VirtualizerItemOptions { layoutInfo: LayoutInfo | null; virtualizer: IVirtualizer; ref: RefObject; + shouldObserveItemSize?: boolean; } export function useVirtualizerItem(options: VirtualizerItemOptions): {updateSize: () => void} { - let {layoutInfo, virtualizer, ref} = options; + let {layoutInfo, virtualizer, ref, shouldObserveItemSize} = options; let key = layoutInfo?.key; let updateSize = useCallback(() => { @@ -40,7 +42,38 @@ export function useVirtualizerItem(options: VirtualizerItemOptions): {updateSize if (layoutInfo?.estimatedSize) { updateSize(); } - }); + }, [layoutInfo?.estimatedSize, updateSize]); + + // TODO: Consider using a MutationObserver in addition to ResizeObserver to detect + // when inner DOM structure changes cause an item's height to change. + // The current ResizeObserver only observes direct children, + // so mutations deeper in the tree won't trigger a remeasure, leading to stale cached heights and overlapping items. + let updateSizeEvent = useEffectEvent(updateSize); + // useResizeObserver observes one element via ref, but the wrapper height is fixed by layout + // and won't change when content grows. Observe direct children instead, then remeasure the + // wrapper in updateSize. + useEffect(() => { + if (!shouldObserveItemSize) { + return; + } + + let el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') { + return; + } + + let resizeObserver = new ResizeObserver(() => { + updateSizeEvent(); + }); + + for (let child of el.children) { + resizeObserver.observe(child); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [shouldObserveItemSize, ref, key]); return {updateSize}; } diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index f200f12584d..fbcfdd0e56c 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -27,6 +27,11 @@ import {Rect} from '../virtualizer/Rect'; import {Size} from '../virtualizer/Size'; export interface ListLayoutOptions { + /** + * Anchors the vertical list content to the end (bottom) of the viewport. When set to `'end'`, + * the viewport stays pinned to the latest content unless the user scrolls up. + */ + anchorTo?: 'end'; /** * The primary orientation of the items. Usually this is the direction that the collection * scrolls. @@ -147,6 +152,7 @@ export class ListLayout protected dropIndicatorThickness: number; protected gap: number; protected padding: number; + protected anchorTo: 'end' | undefined; protected layoutNodes: Map; protected contentSize: Size; protected lastCollection: Collection> | null; @@ -163,6 +169,7 @@ export class ListLayout */ constructor(options: ListLayoutOptions = {}) { super(); + this.anchorTo = options.anchorTo; this.rowSize = options?.rowSize ?? options?.rowHeight ?? null; this.orientation = options.orientation ?? 'vertical'; this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? null; @@ -182,6 +189,13 @@ export class ListLayout this.contentSize = new Size(); } + isReversed(layoutOptions?: O): boolean { + let anchorTo = layoutOptions?.anchorTo ?? this.anchorTo; + let orientation = layoutOptions?.orientation ?? this.orientation; + // reversed layouts are only supported in vertical orientations + return anchorTo === 'end' && orientation !== 'horizontal'; + } + // Backward compatibility for subclassing. protected get collection(): Collection> { return this.virtualizer!.collection; @@ -309,6 +323,7 @@ export class ListLayout invalidationContext.sizeChanged || this.rowSize !== (options?.rowSize ?? options?.rowHeight ?? this.rowSize) || this.orientation !== (options?.orientation ?? this.orientation) || + this.anchorTo !== (options?.anchorTo ?? this.anchorTo) || this.headingSize !== (options?.headingSize ?? options?.headingHeight ?? this.headingSize) || this.loaderSize !== (options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize) || this.gap !== (options?.gap ?? this.gap) || @@ -321,6 +336,7 @@ export class ListLayout (newOptions?.rowSize ?? newOptions?.rowHeight) !== (oldOptions?.rowSize ?? oldOptions?.rowHeight) || newOptions.orientation !== oldOptions.orientation || + newOptions.anchorTo !== oldOptions.anchorTo || (newOptions?.estimatedRowSize ?? newOptions?.estimatedRowHeight) !== (oldOptions?.estimatedRowSize ?? oldOptions?.estimatedRowHeight) || (newOptions?.headingSize ?? newOptions?.headingHeight) !== @@ -349,6 +365,7 @@ export class ListLayout let options = invalidationContext.layoutOptions; this.rowSize = options?.rowSize ?? options?.rowHeight ?? this.rowSize; this.orientation = options?.orientation ?? this.orientation; + this.anchorTo = options?.anchorTo ?? this.anchorTo; this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? this.estimatedRowSize; this.headingSize = options?.headingSize ?? options?.headingHeight ?? this.headingSize; @@ -379,6 +396,10 @@ export class ListLayout } protected buildCollection(offset: number = this.padding): LayoutNode[] { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + return this.buildReversedCollection(); + } + let collection = this.virtualizer!.collection; let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; @@ -445,10 +466,97 @@ export class ListLayout offset = Math.max(offset - this.gap, 0); offset += isEmptyOrLoading ? 0 : this.padding; + let contentLength = offset; this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.size.height) - : new Size(this.virtualizer!.size.width, offset); + : new Size(this.virtualizer!.size.width, contentLength); + + return nodes; + } + + // TODO: promote to protected once the reversed layout API is more stable and tested + private buildReversedCollection(): LayoutNode[] { + let collectionNodes = toArray(this.virtualizer!.collection, node => node.type !== 'content'); + this.assertReversedCollectionSupported(collectionNodes); + + let itemNodes = collectionNodes.filter(node => node.type !== 'loader'); + let loaderCollectionNode = collectionNodes.find(node => node.type === 'loader') ?? null; + + // Height-only pass: we need the sum of all item heights to compute contentSize before we + // can assign y positions. Read cached heights (or estimated fallback) without calling + // buildNode — y is unknown here, so calling it would cache items at the wrong position. + let itemHeights = itemNodes.map(node => { + let cached = this.layoutNodes.get(node.key); + return cached + ? cached.layoutInfo.rect.height + : (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT); + }); + + let contentLength = itemHeights.reduce( + (total, height, index) => total + height + (index < itemHeights.length - 1 ? this.gap : 0), + 0 + ); + if (itemHeights.length > 0) { + contentLength += this.padding * 2; + } + + let contentHeight = Math.max(contentLength, this.virtualizer!.size.height); + this.contentSize = new Size(this.virtualizer!.size.width, contentHeight); + + // Bottom-up position pass: assign y to every item + let width = this.virtualizer!.size.width - this.padding * 2; + let hasLoader = loaderCollectionNode != null; + let offset = hasLoader ? 1 : 0; + let nodes: LayoutNode[] = Array.from({length: itemNodes.length + offset}); + let currentBottom = contentLength - this.padding; + + // Iterate last → first so the last item in the collection (newest) is placed at the visual + // bottom and written to nodes[0] (first in DOM) for screen-reader accessibility. + let nodesIndex = 0; + for (let i = itemNodes.length - 1; i >= 0; i--) { + let node = itemNodes[i]; + let y = currentBottom - itemHeights[i]; + let cached = this.layoutNodes.get(node.key); + let layoutNode: LayoutNode; + + if (cached && !cached.layoutInfo.estimatedSize) { + // Already measured: reuse the real height, update y only + let newLayoutInfo = cached.layoutInfo.copy(); + newLayoutInfo.rect.y = y; + layoutNode = {layoutInfo: newLayoutInfo, validRect: new Rect(0, 0, 0, 0), children: []}; + } else { + let itemRect = new Rect(this.padding, y, width, itemHeights[i]); + if (itemRect.intersects(this.requestedRect)) { + // In the visible window and unmeasured: full build so useVirtualizerItem can measure it + layoutNode = this.buildNode(node, this.padding, y); + } else { + // Outside the visible window and unmeasured: skip buildNode + let layoutInfo = new LayoutInfo(node.type, node.key, itemRect); + layoutInfo.estimatedSize = true; + layoutNode = {layoutInfo, validRect: new Rect(0, 0, 0, 0), children: [], node}; + } + } + + layoutNode.layoutInfo.parentKey = null; + layoutNode.layoutInfo.allowOverflow = true; + layoutNode.validRect = layoutNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(layoutNode.layoutInfo.key, layoutNode); + nodes[nodesIndex++ + offset] = layoutNode; + currentBottom = y - this.gap; + } + + if (loaderCollectionNode) { + // Build the loader at a placeholder y=0, then position it above the oldest item using + let loaderNode = this.buildNode(loaderCollectionNode, this.padding, 0); + loaderNode.layoutInfo.rect.y = currentBottom - loaderNode.layoutInfo.rect.height; + loaderNode.layoutInfo.parentKey = null; + loaderNode.layoutInfo.allowOverflow = true; + loaderNode.validRect = loaderNode.layoutInfo.rect.intersection(this.requestedRect); + this.layoutNodes.set(loaderNode.layoutInfo.key, loaderNode); + nodes[0] = loaderNode; + } + return nodes; } @@ -520,6 +628,12 @@ export class ListLayout } protected buildSection(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + let collection = this.virtualizer!.collection; let width = this.virtualizer!.size.width - this.padding - x; let height = this.virtualizer!.size.height - this.padding - y; @@ -576,6 +690,12 @@ export class ListLayout } protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; let width = @@ -683,6 +803,24 @@ export class ListLayout let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; layoutInfo.estimatedSize = false; + + // Store the real measured height and signal a relayout. Unlike the normal path, we don't + // adjust validRect/requestedRect because buildReversedCollection always recomputes all + // positions from scratch. Items are placed bottom-up, so each item's y depends on the + // heights of every item below it, making incremental invalidation impossible. + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { + let newLayoutInfo = layoutInfo.copy(); + newLayoutInfo.rect[heightProperty] = size[heightProperty]; + newLayoutInfo.estimatedSize = false; + layoutNode.layoutInfo = newLayoutInfo; + this.layoutNodes.set(key, layoutNode); + return true; + } + + return false; + } + if (layoutInfo.rect[heightProperty] !== size[heightProperty]) { // Copy layout info rather than mutating so that later caches are invalidated. let newLayoutInfo = layoutInfo.copy(); @@ -739,6 +877,10 @@ export class ListLayout y: number, isValidDropTarget: (target: DropTarget) => boolean ): DropTarget | null { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + x += this.virtualizer!.visibleRect.x; y += this.virtualizer!.visibleRect.y; @@ -797,6 +939,10 @@ export class ListLayout } getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + if (this.anchorTo === 'end' && this.orientation === 'vertical') { + throw new Error('Drag and drop is not supported for ListLayout with anchorTo="end".'); + } + let layoutInfo = this.getLayoutInfo(target.key)!; let rect: Rect; if (target.dropPosition === 'before') { @@ -851,6 +997,22 @@ export class ListLayout return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); } + + private assertReversedCollectionSupported(nodes: Node[]) { + for (let node of nodes) { + if (node.type !== 'item' && node.type !== 'loader' && node.type !== 'separator') { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + + if (node.parentKey != null || node.level > 0 || node.hasChildNodes) { + throw new Error( + 'ListLayout with anchorTo="end" only supports flat root-level items and an optional root loader.' + ); + } + } + } } function toArray( diff --git a/packages/react-stately/src/virtualizer/Layout.ts b/packages/react-stately/src/virtualizer/Layout.ts index fd585fe7f59..e063455c922 100644 --- a/packages/react-stately/src/virtualizer/Layout.ts +++ b/packages/react-stately/src/virtualizer/Layout.ts @@ -73,6 +73,17 @@ export abstract class Layout, O = any> implements L return newOptions !== oldOptions; } + /** + * Returns whether this layout anchors content to the bottom (content grows upward). + * When true, the virtualizer preserves scroll position across content shifts and + * snaps to the bottom on first render. The `layoutOptions` argument reflects + * incoming options before `update()` applies them, ensuring correctness on the + * first render when internal fields are not yet set. + */ + isReversed(_layoutOptions?: O): boolean { + return false; + } + /** * This method allows the layout to perform any pre-computation * it needs to in order to prepare LayoutInfos for retrieval. diff --git a/packages/react-stately/src/virtualizer/Virtualizer.ts b/packages/react-stately/src/virtualizer/Virtualizer.ts index 7a87676616f..9bfef80262c 100644 --- a/packages/react-stately/src/virtualizer/Virtualizer.ts +++ b/packages/react-stately/src/virtualizer/Virtualizer.ts @@ -12,7 +12,13 @@ import {ChildView, ReusableView, RootView} from './ReusableView'; import {Collection, Key} from '@react-types/shared'; -import {InvalidationContext, Mutable, VirtualizerDelegate, VirtualizerRenderOptions} from './types'; +import { + InvalidationContext, + Mutable, + ScrollAnchor, + VirtualizerDelegate, + VirtualizerRenderOptions +} from './types'; import {isSetEqual} from './utils'; import {Layout} from './Layout'; import {LayoutInfo} from './LayoutInfo'; @@ -70,6 +76,9 @@ export class Virtualizer { private _isScrolling: boolean; private _invalidationContext: InvalidationContext; private _overscanManager: OverscanManager; + private _hasInitializedReverseAnchor: boolean; + private _isAnchoredToEnd: boolean; + private _scrollEndThreshold: number; constructor(options: VirtualizerOptions) { this.delegate = options.delegate; @@ -85,6 +94,9 @@ export class Virtualizer { this._isScrolling = false; this._invalidationContext = {}; this._overscanManager = new OverscanManager(); + this._hasInitializedReverseAnchor = false; + this._isAnchoredToEnd = false; + this._scrollEndThreshold = 0; } /** Returns whether the given key, or an ancestor, is persisted. */ @@ -167,9 +179,69 @@ export class Virtualizer { } private relayout(context: InvalidationContext = {}) { + let isAnchoredToEnd = this._isAnchoredToEnd; + let scrollEndThreshold = this._scrollEndThreshold; + + // Capture scroll anchor from current (pre-layout) view positions. + // Returns non-null only when isAnchoredToEnd is true AND a visible item was found. + // On first render _visibleViews is empty so _getScrollAnchor returns null. + let anchor = this._getScrollAnchor(); + let previousRawContentHeight = this.contentSize.height; + let previousVisibleRect = this.visibleRect; + // Update the layout this.layout.update(context); - (this as Mutable).contentSize = this.layout.getContentSize(); + + let rawContentSize = this.layout.getContentSize(); + let rawContentHeight = rawContentSize.height; + (this as Mutable).contentSize = new Size(rawContentSize.width, rawContentHeight); + + if (isAnchoredToEnd && previousVisibleRect.area > 0) { + let contentHeightDelta = rawContentHeight - previousRawContentHeight; + let distanceFromEnd = previousRawContentHeight - previousVisibleRect.maxY; + let wasNearBottom = distanceFromEnd <= scrollEndThreshold; + + if (!this._hasInitializedReverseAnchor) { + // Always snap to bottom on first layout + this._hasInitializedReverseAnchor = true; + if (this._snapToBottom(previousVisibleRect)) { + return; + } + } else if (contentHeightDelta !== 0 || context.itemSizeChanged) { + // Only modify scroll when content actually changed — prevents the setVisibleRect + // re-render after the initial snap from triggering a spurious anchor-restore. + if (anchor) { + // Two things can increase content height, and they need different handling. + // Old messages prepended at the top push existing items down (y increases) — we must adjust + // scroll to compensate so the user's view doesn't jump. + // New messages appended at the bottom leave existing items in place (y unchanged) — no adjustment needed. + let preLayoutCornerY = anchor.offset + previousVisibleRect.y; + let freshInfo = this.layout.getLayoutInfo(anchor.key); + let anchorShiftedDown = + freshInfo != null && freshInfo.rect[anchor.corner].y > preLayoutCornerY; + + // Queues a new render cycle. Return early to skip updateSubviews — running it now + // would position views against the old visibleRect, causing a flash before the + // incoming relayout corrects them. + if ( + this._applyReverseAnchorScroll( + anchor, + wasNearBottom, + anchorShiftedDown, + previousVisibleRect, + context.itemSizeChanged + ) + ) { + return; + } + } else if (wasNearBottom && !this._isScrolling) { + // No anchor captured (views not yet populated): fall back to near-bottom snap. + if (this._snapToBottom(previousVisibleRect)) { + return; + } + } + } + } // Constrain scroll position. // If the content changed, scroll to the top. @@ -194,6 +266,126 @@ export class Virtualizer { } } + // Helper function that decides how to adjust the scroll position after content height changes in a reversed layout. + // There are four cases: + // 1. Streaming (item grew in place, user near bottom): snap to bottom. + // 2. History load (old messages prepended, items shifted down): scroll down to keep the + // same item visible. + // 3. New message appended, user near bottom: snap to bottom. + // 4. User scrolled up: preserve their position regardless of what changed. + // Returns true if a scroll adjustment was made, false if no change was needed. + private _applyReverseAnchorScroll( + anchor: ScrollAnchor, + wasNearBottom: boolean, + anchorShiftedDown: boolean, + previousVisibleRect: Rect, + itemSizeChanged: boolean | undefined + ): boolean { + if (wasNearBottom && !this._isScrolling && itemSizeChanged) { + return this._snapToBottom(previousVisibleRect); + } else if (anchorShiftedDown) { + let targetY = this._computeScrollAnchorTarget(anchor); + if (targetY != null) { + let rect = new Rect( + previousVisibleRect.x, + targetY, + previousVisibleRect.width, + previousVisibleRect.height + ); + this.delegate.setVisibleRect(rect); + return true; + } + } else if (wasNearBottom && !this._isScrolling) { + return this._snapToBottom(previousVisibleRect); + } else { + if (this._restoreScrollAnchor(anchor)) { + return true; + } + } + return false; + } + + // Pins the viewport to the bottom of the content + private _snapToBottom(previousVisibleRect: Rect): boolean { + let maxVisibleY = Math.max(0, this.contentSize.height - previousVisibleRect.height); + let target = new Rect( + previousVisibleRect.x, + maxVisibleY, + previousVisibleRect.width, + previousVisibleRect.height + ); + if (!target.equals(this.visibleRect)) { + this.delegate.setVisibleRect(target); + return true; + } + return false; + } + + // Captures a reference point before a layout change so the user's scroll position can + // be restored afterward. Finds the visible item whose top edge sits closest to the top + // of the viewport, and records its key and distance from the viewport top. + // Top corners are always used. Returns null if the layout is not reversed. + private _getScrollAnchor(): ScrollAnchor | null { + if (!this._isAnchoredToEnd) { + return null; + } + let visibleRect = this.visibleRect; + + let best: ScrollAnchor | null = null; + for (let [key, view] of this._visibleViews) { + let layoutInfo = view.layoutInfo; + if (layoutInfo && layoutInfo.rect.area > 0 && layoutInfo.rect.intersects(visibleRect)) { + let corner = layoutInfo.rect.getCornerInRect(visibleRect) ?? 'topLeft'; + // Force top corners: bottom corners on a clipped item can drift if the item resizes. + if (corner === 'bottomLeft') { + corner = 'topLeft'; + } + if (corner === 'bottomRight') { + corner = 'topRight'; + } + let offset = layoutInfo.rect[corner].y - visibleRect.y; + if (!best || offset < best.offset) { + best = {key, corner, offset}; + } + } + } + return best; + } + + // Computes the viewport y position needed to keep the anchor item at the same relative + // position it had before the layout change. Measures how far the anchor item shifted, + // then offsets the viewport by the same amount. Clamps to valid scroll bounds. + // Returns null if no adjustment is needed, or if clamping would leave the viewport unchanged. + private _computeScrollAnchorTarget(anchor: ScrollAnchor): number | null { + let finalInfo = this.layout.getLayoutInfo(anchor.key); + if (!finalInfo) { + return null; + } + let visibleRect = this.visibleRect; + let adjustment = finalInfo.rect[anchor.corner].y - visibleRect.y - anchor.offset; + if (adjustment === 0) { + return null; + } + let targetY = visibleRect.y + adjustment; + let maxY = Math.max(0, this.contentSize.height - visibleRect.height); + let clampedY = Math.max(0, Math.min(maxY, targetY)); + return clampedY !== visibleRect.y ? clampedY : null; + } + + // Applies the scroll adjustment computed from the anchor to keep the user's view stable. + // Returns true if scroll changed, false if the anchor item didn't move or is gone. + private _restoreScrollAnchor(anchor: ScrollAnchor): boolean { + let targetY = this._computeScrollAnchorTarget(anchor); + if (targetY == null) { + return false; + } + let visibleRect = this.visibleRect; + this.delegate.setVisibleRect( + new Rect(visibleRect.x, targetY, visibleRect.width, visibleRect.height) + ); + return true; + } + getVisibleLayoutInfos(): Map { let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON; let isClientWidthMocked = @@ -286,6 +478,9 @@ export class Virtualizer { let layoutOptionsChanged = false; let needsUpdate = false; + let isReversed = opts.layout.isReversed(opts.layoutOptions); + let scrollEndThreshold = opts.scrollEndThreshold ?? 0; + if (opts.collection !== this.collection) { mutableThis.collection = opts.collection; needsLayout = true; @@ -298,6 +493,17 @@ export class Virtualizer { opts.layout.virtualizer = this; mutableThis.layout = opts.layout; + this._hasInitializedReverseAnchor = false; + needsLayout = true; + } + + // If the reversed mode was off and is now being turned on, reset the initialization flag so the initial snap-to-bottom fires. + if (isReversed !== this._isAnchoredToEnd || scrollEndThreshold !== this._scrollEndThreshold) { + if (!this._isAnchoredToEnd && isReversed) { + this._hasInitializedReverseAnchor = false; + } + this._isAnchoredToEnd = isReversed; + this._scrollEndThreshold = scrollEndThreshold; needsLayout = true; } diff --git a/packages/react-stately/src/virtualizer/types.ts b/packages/react-stately/src/virtualizer/types.ts index 7944dceedd8..a094931ab2a 100644 --- a/packages/react-stately/src/virtualizer/types.ts +++ b/packages/react-stately/src/virtualizer/types.ts @@ -12,7 +12,7 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; -import {Rect} from './Rect'; +import {Rect, RectCorner} from './Rect'; import {Size} from './Size'; export interface InvalidationContext { @@ -24,6 +24,12 @@ export interface InvalidationContext { layoutOptions?: O; } +export interface ScrollAnchor { + key: Key; + corner: RectCorner; + offset: number; +} + export interface VirtualizerDelegate { setVisibleRect(rect: Rect): void; renderView(type: string, content: T | null): V; @@ -39,6 +45,7 @@ export interface VirtualizerRenderOptions { invalidationContext: InvalidationContext; isScrolling: boolean; layoutOptions?: O; + scrollEndThreshold?: number; } export type Mutable = { diff --git a/packages/react-stately/src/virtualizer/useVirtualizerState.ts b/packages/react-stately/src/virtualizer/useVirtualizerState.ts index d360244ca31..dd94f4733f9 100644 --- a/packages/react-stately/src/virtualizer/useVirtualizerState.ts +++ b/packages/react-stately/src/virtualizer/useVirtualizerState.ts @@ -33,6 +33,7 @@ interface VirtualizerProps { persistedKeys?: Set | null; layoutOptions?: O; allowsWindowScrolling?: boolean; + scrollEndThreshold?: number; } export interface VirtualizerState { @@ -96,7 +97,8 @@ export function useVirtualizerState( visibleRect, size: opts.allowsWindowScrolling ? size : visibleRect, invalidationContext: mergedInvalidationContext, - isScrolling + isScrolling, + scrollEndThreshold: opts.scrollEndThreshold }); let contentSize = virtualizer.contentSize;