Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-aria-components/src/Collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ export interface CollectionRenderer {
layoutDelegate?: LayoutDelegate;
/** A delegate object that provides drop targets for pointer coordinates within the collection. */
dropTargetDelegate?: DropTargetDelegate;
/** Refreshes the virtualized collection's visible rect after programmatic scrolling. */
refreshVisibleRect?: () => void;
/** A component that renders the root collection items. */
CollectionRoot: React.ComponentType<CollectionRootProps>;
/** A component that renders the child collection items. */
Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ function GridListInner<T>({props, collection, gridListRef: ref}: GridListInnerPr
CollectionRoot,
isVirtualized,
layoutDelegate,
dropTargetDelegate: ctxDropTargetDelegate
dropTargetDelegate: ctxDropTargetDelegate,
refreshVisibleRect
} = useContext(CollectionRendererContext);
let gridlistState = useListState({
...DOMCollectionProps,
Expand Down Expand Up @@ -293,6 +294,7 @@ function GridListInner<T>({props, collection, gridListRef: ref}: GridListInnerPr
// Only tab navigation is supported in grid layout.
keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior,
isVirtualized,
UNSTABLE_virtualizerRefresh: refreshVisibleRect,
shouldSelectOnPressUp: props.shouldSelectOnPressUp,
disallowTypeAhead
},
Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ function ListBoxInner<T>({state: inputState, props, listBoxRef}: ListBoxInnerPro
isVirtualized,
layoutDelegate,
dropTargetDelegate: ctxDropTargetDelegate,
refreshVisibleRect,
CollectionRoot
} = useContext(CollectionRendererContext);
let keyboardDelegate = useMemo(
Expand Down Expand Up @@ -285,7 +286,8 @@ function ListBoxInner<T>({state: inputState, props, listBoxRef}: ListBoxInnerPro
...props,
shouldSelectOnPressUp: isListDraggable || props.shouldSelectOnPressUp,
keyboardDelegate,
isVirtualized
isVirtualized,
UNSTABLE_virtualizerRefresh: refreshVisibleRect
},
state,
listBoxRef
Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -721,14 +721,16 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl
isVirtualized,
layoutDelegate,
dropTargetDelegate: ctxDropTargetDelegate,
refreshVisibleRect,
CollectionRoot
} = useContext(CollectionRendererContext);
let {dragAndDropHooks} = props;
let {gridProps} = useTable(
{
...DOMCollectionProps,
layoutDelegate,
isVirtualized
isVirtualized,
UNSTABLE_virtualizerRefresh: refreshVisibleRect
},
filteredState,
ref
Expand Down
15 changes: 12 additions & 3 deletions packages/react-aria-components/src/Virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
useVirtualizerState,
VirtualizerState
} from 'react-stately/useVirtualizerState';
import React, {createContext, JSX, ReactNode, useContext, useMemo} from 'react';
import React, {createContext, JSX, ReactNode, RefObject, useContext, useMemo, useRef} from 'react';
import {useScrollView} from 'react-aria/private/virtualizer/ScrollView';
import {VirtualizerItem} from 'react-aria/private/virtualizer/VirtualizerItem';

Expand Down Expand Up @@ -57,6 +57,7 @@ interface LayoutContextValue {

const VirtualizerContext = createContext<VirtualizerState<any, any> | null>(null);
const LayoutContext = createContext<LayoutContextValue | null>(null);
const RefreshVisibleRectContext = createContext<RefObject<(() => void) | null> | null>(null);

/**
* A Virtualizer renders a scrollable collection of data using customizable layouts.
Expand All @@ -69,13 +70,15 @@ export function Virtualizer<O>(props: VirtualizerProps<O>): JSX.Element {
() => (typeof layoutProp === 'function' ? new layoutProp() : layoutProp),
[layoutProp]
);
let refreshVisibleRectRef = useRef<(() => void) | null>(null);
let renderer: CollectionRenderer = useMemo(
() => ({
isVirtualized: true,
layoutDelegate: layout,
dropTargetDelegate: layout.getDropTargetFromPoint
? (layout as DropTargetDelegate)
: undefined,
refreshVisibleRect: () => refreshVisibleRectRef.current?.(),
CollectionRoot,
CollectionBranch
}),
Expand All @@ -84,7 +87,9 @@ export function Virtualizer<O>(props: VirtualizerProps<O>): JSX.Element {

return (
<CollectionRendererContext.Provider value={renderer}>
<LayoutContext.Provider value={{layout, layoutOptions}}>{children}</LayoutContext.Provider>
<RefreshVisibleRectContext.Provider value={refreshVisibleRectRef}>
<LayoutContext.Provider value={{layout, layoutOptions}}>{children}</LayoutContext.Provider>
</RefreshVisibleRectContext.Provider>
</CollectionRendererContext.Provider>
);
}
Expand Down Expand Up @@ -120,7 +125,7 @@ function CollectionRoot({
}, [layoutOptions, layoutOptions2])
});

let {contentProps} = useScrollView(
let {contentProps, refreshVisibleRect} = useScrollView(
{
onVisibleRectChange: state.setVisibleRect,
onSizeChange: state.setSize,
Expand All @@ -131,6 +136,10 @@ function CollectionRoot({
},
scrollRef!
);
let refreshVisibleRectRef = useContext(RefreshVisibleRectContext);
if (refreshVisibleRectRef) {
refreshVisibleRectRef.current = refreshVisibleRect;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the rules of hooks, cannot read or write to a ref during render

}

return (
<div {...contentProps}>
Expand Down
81 changes: 81 additions & 0 deletions packages/react-aria-components/stories/ListBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,87 @@ export const AsyncListBoxVirtualized: StoryFn<typeof AsyncListBoxRender> = args
);
};

interface AsyncVirtualizedEndKeyItem {
id: number;
name: string;
}

export const AsyncListBoxVirtualizedEndKey: StoryFn<typeof AsyncListBoxRender> = args => {
let list = useAsyncList<AsyncVirtualizedEndKeyItem>({
async load({cursor}) {
let page = cursor ? Number(cursor) : 0;
let pageSize = 25;
let pageCount = 12;
let start = page * pageSize;

await new Promise(resolve => setTimeout(resolve, args.delay));

return {
items: Array.from({length: pageSize}, (_, index) => ({
id: start + index,
name: `Item ${start + index}`
})),
cursor: page < pageCount - 1 ? String(page + 1) : undefined
};
}
});

return (
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
<p style={{margin: 0, maxWidth: 520}}>
Focus the listbox, press End, wait for more items to load, and repeat. The focused item
should move to the last loaded item and scroll into view after each load.
</p>
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 50,
padding: 4,
loaderHeight: 30
}}>
<ListBox
{...args}
style={{
height: 400,
width: 160,
border: '1px solid gray',
background: 'lightgray',
overflow: 'auto',
padding: 'unset',
display: 'flex'
}}
aria-label="async virtualized end key issue listbox"
renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}>
<Collection items={list.items}>
{item => (
<MyListBoxItem
style={{
backgroundColor: 'lightgrey',
border: '1px solid black',
boxSizing: 'border-box',
height: '100%',
width: '100%'
}}
id={item.id}>
{item.name}
</MyListBoxItem>
)}
</Collection>
<MyListBoxLoaderIndicator
isLoading={list.loadingState === 'loadingMore'}
onLoadMore={list.loadMore}
/>
</ListBox>
</Virtualizer>
</div>
);
};

AsyncListBoxVirtualizedEndKey.args = {
delay: 500,
orientation: 'vertical'
};

export const ListBoxScrollMargin: ListBoxStory = args => {
let items: {id: number; name: string; description: string}[] = [];
for (let i = 0; i < 100; i++) {
Expand Down
131 changes: 128 additions & 3 deletions packages/react-aria-components/test/ListBox.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@
* governing permissions and limitations under the License.
*/

import {expect, it} from 'vitest';
import {ListBox, ListBoxItem} from '../src/ListBox';
import React from 'react';
import {Collection} from 'react-aria/Collection';
import {expect, it, vi} from 'vitest';
import {ListBox, ListBoxItem, ListBoxLoadMoreItem} from '../src/ListBox';
import {ListLayout} from 'react-stately/useVirtualizerState';
import React, {act} from 'react';
import {render} from 'vitest-browser-react';
import {useAsyncList} from 'react-stately/useAsyncList';
import {User} from '@react-aria/test-utils';
import {userEvent} from 'vitest/browser';
import {Virtualizer} from '../src/Virtualizer';

function GridListBox() {
return (
Expand All @@ -36,6 +41,91 @@ function GridListBox() {
);
}

interface AsyncVirtualizedItem {
id: number;
name: string;
}

let asyncVirtualizedItems: AsyncVirtualizedItem[] = Array.from({length: 300}, (_, index) => ({
id: index,
name: `Item ${index}`
}));

function AsyncVirtualizedListBox() {
let list = useAsyncList<AsyncVirtualizedItem>({
async load({cursor}) {
let page = cursor ? Number(cursor) : 0;
let pageSize = 25;
let pageCount = 12;
let start = page * pageSize;

await new Promise(resolve => setTimeout(resolve, 50));

return {
items: asyncVirtualizedItems.slice(start, start + pageSize),
cursor: page < pageCount - 1 ? String(page + 1) : undefined
};
}
});

return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 50,
padding: 4,
loaderHeight: 30
}}>
<ListBox
aria-label="async virtualized end key issue listbox"
style={{
height: 400,
width: 160,
border: '1px solid gray',
background: 'lightgray',
overflow: 'auto',
padding: 'unset',
display: 'flex'
}}>
<Collection items={list.items}>
{item => (
<ListBoxItem
id={item.id}
style={{
backgroundColor: 'lightgrey',
border: '1px solid black',
boxSizing: 'border-box',
height: '100%',
width: '100%'
}}>
{item.name}
</ListBoxItem>
)}
</Collection>
<ListBoxLoadMoreItem
isLoading={list.loadingState === 'loadingMore'}
onLoadMore={list.loadMore}>
Loading...
</ListBoxLoadMoreItem>
</ListBox>
</Virtualizer>
);
}

function expectFocusedOptionInView(listbox: HTMLElement, text?: string) {
let activeElement = document.activeElement as HTMLElement;
if (text != null) {
expect(activeElement.textContent).toBe(text);
} else {
expect(activeElement.textContent).toMatch(/^Item /);
}

let listboxRect = listbox.getBoundingClientRect();
let activeRect = activeElement.getBoundingClientRect();
expect(activeRect.top).toBeGreaterThanOrEqual(listboxRect.top);
expect(activeRect.bottom).toBeLessThanOrEqual(listboxRect.bottom);
}

it.each`
interactionType
${'mouse'}
Expand Down Expand Up @@ -67,3 +157,38 @@ it.each`
expect(document.activeElement).toBe(options[8]);
}
);

it('moves focus and scrolls to the last loaded item with End in a virtualized async listbox', async () => {
let {container} = await render(<AsyncVirtualizedListBox />);
let listbox = container.querySelector('[role=listbox]') as HTMLElement;

await vi.waitFor(() => {
expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0');
});
act(() => listbox.focus());

for (let page = 1; page <= 6; page++) {
await userEvent.keyboard('{End}');
await new Promise(resolve => setTimeout(resolve, 100));

let lastItem = `Item ${page * 25 - 1}`;
await vi.waitFor(() => expectFocusedOptionInView(listbox, lastItem));
}
});

it('keeps the focused item visible while paging through a virtualized async listbox', async () => {
let {container} = await render(<AsyncVirtualizedListBox />);
let listbox = container.querySelector('[role=listbox]') as HTMLElement;

await vi.waitFor(() => {
expect(listbox.querySelector('[role=option]')?.textContent).toBe('Item 0');
});
act(() => listbox.focus());

for (let i = 0; i < 40; i++) {
await userEvent.keyboard('{PageDown}');
await new Promise(resolve => setTimeout(resolve, 100));

await vi.waitFor(() => expectFocusedOptionInView(listbox));
}
});
4 changes: 4 additions & 0 deletions packages/react-aria/src/grid/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {useSelectableCollection} from '../selection/useSelectableCollection';
export interface GridProps extends DOMProps, AriaLabelingProps {
/** Whether the grid uses virtual scrolling. */
isVirtualized?: boolean;
/** @private Refreshes The virtualizer visible rect after programmatic scrolling. */
UNSTABLE_virtualizerRefresh?: () => void;
/**
* Whether typeahead navigation is disabled.
*
Expand Down Expand Up @@ -105,6 +107,7 @@ export function useGrid<T>(
): GridAria {
let {
isVirtualized,
UNSTABLE_virtualizerRefresh,
disallowTypeAhead,
keyboardDelegate,
focusMode,
Expand Down Expand Up @@ -155,6 +158,7 @@ export function useGrid<T>(
selectionManager: manager,
keyboardDelegate: delegate,
isVirtualized,
UNSTABLE_virtualizerRefresh,
scrollRef,
disallowTypeAhead,
escapeKeyBehavior
Expand Down
Loading