Skip to content

Commit 6288d4a

Browse files
authored
Refactor virtualizer layouts to remove Spectrum-specific logic and improve backward compatibility (#6613)
1 parent 7dd7b48 commit 6288d4a

File tree

17 files changed

+370
-238
lines changed

17 files changed

+370
-238
lines changed

packages/@react-aria/dnd/stories/VirtualizedListBox.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,7 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) {
137137

138138
let layout = React.useMemo(() =>
139139
new ListLayout<unknown>({
140-
estimatedRowHeight: 32,
141-
padding: 8,
142-
loaderHeight: 40,
143-
placeholderHeight: 32
140+
estimatedRowHeight: 32
144141
})
145142
, []);
146143

packages/@react-aria/grid/src/GridKeyboardDelegate.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared';
13+
import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Rect, Size} from '@react-types/shared';
1414
import {DOMLayoutDelegate} from '@react-aria/selection';
1515
import {getChildNodes, getFirstItem, getLastItem, getNthItem} from '@react-stately/collections';
1616
import {GridCollection} from '@react-types/grid';
@@ -24,6 +24,8 @@ export interface GridKeyboardDelegateOptions<C> {
2424
direction: Direction,
2525
collator?: Intl.Collator,
2626
layoutDelegate?: LayoutDelegate,
27+
/** @deprecated - Use layoutDelegate instead. */
28+
layout?: DeprecatedLayout,
2729
focusMode?: 'row' | 'cell'
2830
}
2931

@@ -42,7 +44,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
4244
this.disabledBehavior = options.disabledBehavior || 'all';
4345
this.direction = options.direction;
4446
this.collator = options.collator;
45-
this.layoutDelegate = options.layoutDelegate || new DOMLayoutDelegate(options.ref);
47+
this.layoutDelegate = options.layoutDelegate || (options.layout ? new DeprecatedLayoutDelegate(options.layout) : new DOMLayoutDelegate(options.ref));
4648
this.focusMode = options.focusMode || 'row';
4749
}
4850

@@ -356,3 +358,38 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
356358
return null;
357359
}
358360
}
361+
362+
/* Backward compatibility for old Virtualizer Layout interface. */
363+
interface DeprecatedLayout {
364+
getLayoutInfo(key: Key): DeprecatedLayoutInfo,
365+
getContentSize(): Size,
366+
virtualizer: DeprecatedVirtualizer
367+
}
368+
369+
interface DeprecatedLayoutInfo {
370+
rect: Rect
371+
}
372+
373+
interface DeprecatedVirtualizer {
374+
visibleRect: Rect
375+
}
376+
377+
class DeprecatedLayoutDelegate implements LayoutDelegate {
378+
layout: DeprecatedLayout;
379+
380+
constructor(layout: DeprecatedLayout) {
381+
this.layout = layout;
382+
}
383+
384+
getContentSize(): Size {
385+
return this.layout.getContentSize();
386+
}
387+
388+
getItemRect(key: Key): Rect | null {
389+
return this.layout.getLayoutInfo(key)?.rect || null;
390+
}
391+
392+
getVisibleRect(): Rect {
393+
return this.layout.virtualizer.visibleRect;
394+
}
395+
}

packages/@react-aria/table/src/useTable.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {GridAria, GridProps, useGrid} from '@react-aria/grid';
1515
import {gridIds} from './utils';
1616
// @ts-ignore
1717
import intlMessages from '../intl/*.json';
18-
import {LayoutDelegate} from '@react-types/shared';
18+
import {Key, LayoutDelegate, Rect, Size} from '@react-types/shared';
1919
import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils';
2020
import {RefObject, useMemo} from 'react';
2121
import {TableKeyboardDelegate} from './TableKeyboardDelegate';
@@ -25,7 +25,23 @@ import {useCollator, useLocale, useLocalizedStringFormatter} from '@react-aria/i
2525

2626
export interface AriaTableProps extends GridProps {
2727
/** The layout object for the table. Computes what content is visible and how to position and style them. */
28-
layoutDelegate?: LayoutDelegate
28+
layoutDelegate?: LayoutDelegate,
29+
/** @deprecated - Use layoutDelegate instead. */
30+
layout?: DeprecatedLayout
31+
}
32+
33+
interface DeprecatedLayout {
34+
getLayoutInfo(key: Key): DeprecatedLayoutInfo,
35+
getContentSize(): Size,
36+
virtualizer: DeprecatedVirtualizer
37+
}
38+
39+
interface DeprecatedLayoutInfo {
40+
rect: Rect
41+
}
42+
43+
interface DeprecatedVirtualizer {
44+
visibleRect: Rect
2945
}
3046

3147
/**
@@ -40,7 +56,8 @@ export function useTable<T>(props: AriaTableProps, state: TableState<T> | TreeGr
4056
let {
4157
keyboardDelegate,
4258
isVirtualized,
43-
layoutDelegate
59+
layoutDelegate,
60+
layout
4461
} = props;
4562

4663
// By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down).
@@ -55,8 +72,9 @@ export function useTable<T>(props: AriaTableProps, state: TableState<T> | TreeGr
5572
ref,
5673
direction,
5774
collator,
58-
layoutDelegate
59-
}), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate]);
75+
layoutDelegate,
76+
layout
77+
}), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate, layout]);
6078
let id = useId(props.id);
6179
gridIds.set(state, id);
6280

packages/@react-aria/virtualizer/src/VirtualizerItem.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,7 @@ export function layoutInfoToStyle(layoutInfo: LayoutInfo, dir: Direction, parent
7474
position: layoutInfo.isSticky ? 'sticky' : 'absolute',
7575
// Sticky elements are positioned in normal document flow. Display inline-block so that they don't push other sticky columns onto the following rows.
7676
display: layoutInfo.isSticky ? 'inline-block' : undefined,
77-
// Use clip instead of hidden to avoid creating an implicit generic container in the accessibility tree in Firefox.
78-
// Hidden still allows programmatic scrolling whereas clip does not.
79-
overflow: layoutInfo.allowOverflow ? 'visible' : 'clip',
77+
overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden',
8078
opacity: layoutInfo.opacity,
8179
zIndex: layoutInfo.zIndex,
8280
transform: layoutInfo.transform,

packages/@react-spectrum/list/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@react-stately/collections": "^3.10.7",
5555
"@react-stately/layout": "^3.13.9",
5656
"@react-stately/list": "^3.10.5",
57+
"@react-stately/virtualizer": "^3.7.1",
5758
"@react-types/grid": "^3.2.6",
5859
"@react-types/shared": "^3.23.1",
5960
"@spectrum-icons/ui": "^3.6.7",

packages/@react-spectrum/list/src/ListView.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ import InsertionIndicator from './InsertionIndicator';
2222
// @ts-ignore
2323
import intlMessages from '../intl/*.json';
2424
import {ListKeyboardDelegate} from '@react-aria/selection';
25-
import {ListLayout} from '@react-stately/layout';
2625
import {ListState, useListState} from '@react-stately/list';
2726
import listStyles from './styles.css';
2827
import {ListViewItem} from './ListViewItem';
28+
import {ListViewLayout} from './ListViewLayout';
2929
import {ProgressCircle} from '@react-spectrum/progress';
3030
import React, {JSX, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
3131
import RootDropIndicator from './RootDropIndicator';
@@ -70,7 +70,7 @@ interface ListViewContextValue<T> {
7070
onAction:(key: Key) => void,
7171
isListDraggable: boolean,
7272
isListDroppable: boolean,
73-
layout: ListLayout<T>,
73+
layout: ListViewLayout<T>,
7474
loadingState: LoadingState,
7575
renderEmptyState?: () => JSX.Element
7676
}
@@ -94,16 +94,12 @@ const ROW_HEIGHTS = {
9494

9595
function useListLayout<T>(state: ListState<T>, density: SpectrumListViewProps<T>['density'], overflowMode: SpectrumListViewProps<T>['overflowMode']) {
9696
let {scale} = useProvider();
97-
let isEmpty = state.collection.size === 0;
9897
let layout = useMemo(() =>
99-
new ListLayout<T>({
100-
estimatedRowHeight: ROW_HEIGHTS[density][scale],
101-
padding: 0,
102-
loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale],
103-
enableEmptyState: true
98+
new ListViewLayout<T>({
99+
estimatedRowHeight: ROW_HEIGHTS[density][scale]
104100
})
105101
// eslint-disable-next-line react-hooks/exhaustive-deps
106-
, [scale, density, isEmpty, overflowMode]);
102+
, [scale, density, overflowMode]);
107103

108104
return layout;
109105
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {InvalidationContext, LayoutInfo, Rect} from '@react-stately/virtualizer';
2+
import {LayoutNode, ListLayout} from '@react-stately/layout';
3+
import {Node} from '@react-types/shared';
4+
5+
interface ListViewLayoutProps {
6+
isLoading?: boolean
7+
}
8+
9+
export class ListViewLayout<T> extends ListLayout<T, ListViewLayoutProps> {
10+
private isLoading: boolean = false;
11+
12+
validate(invalidationContext: InvalidationContext<ListViewLayoutProps>): void {
13+
this.isLoading = invalidationContext.layoutOptions?.isLoading || false;
14+
super.validate(invalidationContext);
15+
}
16+
17+
protected buildCollection(): LayoutNode[] {
18+
let nodes = super.buildCollection();
19+
let y = this.contentSize.height;
20+
21+
if (this.isLoading) {
22+
let rect = new Rect(0, y, this.virtualizer.visibleRect.width, nodes.length === 0 ? this.virtualizer.visibleRect.height : this.estimatedRowHeight);
23+
let loader = new LayoutInfo('loader', 'loader', rect);
24+
let node = {
25+
layoutInfo: loader,
26+
validRect: loader.rect
27+
};
28+
nodes.push(node);
29+
this.layoutNodes.set(loader.key, node);
30+
y = loader.rect.maxY;
31+
}
32+
33+
if (nodes.length === 0) {
34+
let rect = new Rect(0, y, this.virtualizer.visibleRect.width, this.virtualizer.visibleRect.height);
35+
let placeholder = new LayoutInfo('placeholder', 'placeholder', rect);
36+
let node = {
37+
layoutInfo: placeholder,
38+
validRect: placeholder.rect
39+
};
40+
nodes.push(node);
41+
this.layoutNodes.set(placeholder.key, node);
42+
y = placeholder.rect.maxY;
43+
}
44+
45+
this.contentSize.height = y;
46+
return nodes;
47+
}
48+
49+
protected buildItem(node: Node<T>, x: number, y: number): LayoutNode {
50+
let res = super.buildItem(node, x, y);
51+
// allow overflow so the focus ring/selection ring can extend outside to overlap with the adjacent items borders
52+
res.layoutInfo.allowOverflow = true;
53+
return res;
54+
}
55+
}

packages/@react-spectrum/listbox/src/ListBoxBase.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ import {FocusScope} from '@react-aria/focus';
1717
// @ts-ignore
1818
import intlMessages from '../intl/*.json';
1919
import {ListBoxContext} from './ListBoxContext';
20+
import {ListBoxLayout} from './ListBoxLayout';
2021
import {ListBoxOption} from './ListBoxOption';
2122
import {ListBoxSection} from './ListBoxSection';
22-
import {ListLayout} from '@react-stately/layout';
2323
import {ListState} from '@react-stately/list';
2424
import {mergeProps} from '@react-aria/utils';
2525
import {ProgressCircle} from '@react-spectrum/progress';
@@ -31,7 +31,7 @@ import {useProvider} from '@react-spectrum/provider';
3131
import {Virtualizer, VirtualizerItem} from '@react-aria/virtualizer';
3232

3333
interface ListBoxBaseProps<T> extends AriaListBoxOptions<T>, DOMProps, AriaLabelingProps, StyleProps {
34-
layout: ListLayout<T>,
34+
layout: ListBoxLayout<T>,
3535
state: ListState<T>,
3636
autoFocus?: boolean | FocusStrategy,
3737
shouldFocusWrap?: boolean,
@@ -48,17 +48,14 @@ interface ListBoxBaseProps<T> extends AriaListBoxOptions<T>, DOMProps, AriaLabel
4848
}
4949

5050
/** @private */
51-
export function useListBoxLayout<T>(): ListLayout<T> {
51+
export function useListBoxLayout<T>(): ListBoxLayout<T> {
5252
let {scale} = useProvider();
5353
let layout = useMemo(() =>
54-
new ListLayout<T>({
54+
new ListBoxLayout<T>({
5555
estimatedRowHeight: scale === 'large' ? 48 : 32,
5656
estimatedHeadingHeight: scale === 'large' ? 33 : 26,
5757
padding: scale === 'large' ? 5 : 4, // TODO: get from DNA
58-
loaderHeight: 40,
59-
placeholderHeight: scale === 'large' ? 48 : 32,
60-
forceSectionHeaders: true,
61-
enableEmptyState: true
58+
placeholderHeight: scale === 'large' ? 48 : 32
6259
})
6360
, [scale]);
6461

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {InvalidationContext, LayoutInfo, Rect} from '@react-stately/virtualizer';
2+
import {LayoutNode, ListLayout, ListLayoutOptions} from '@react-stately/layout';
3+
import {Node} from '@react-types/shared';
4+
5+
interface ListBoxLayoutProps {
6+
isLoading?: boolean
7+
}
8+
9+
interface ListBoxLayoutOptions extends ListLayoutOptions {
10+
placeholderHeight: number,
11+
padding: number
12+
}
13+
14+
export class ListBoxLayout<T> extends ListLayout<T, ListBoxLayoutProps> {
15+
private isLoading: boolean = false;
16+
private placeholderHeight: number;
17+
private padding: number;
18+
19+
constructor(opts: ListBoxLayoutOptions) {
20+
super(opts);
21+
this.placeholderHeight = opts.placeholderHeight;
22+
this.padding = opts.padding;
23+
}
24+
25+
validate(invalidationContext: InvalidationContext<ListBoxLayoutProps>): void {
26+
this.isLoading = invalidationContext.layoutOptions?.isLoading || false;
27+
super.validate(invalidationContext);
28+
}
29+
30+
protected buildCollection(): LayoutNode[] {
31+
let nodes = super.buildCollection(this.padding);
32+
let y = this.contentSize.height;
33+
34+
if (this.isLoading) {
35+
let rect = new Rect(0, y, this.virtualizer.visibleRect.width, 40);
36+
let loader = new LayoutInfo('loader', 'loader', rect);
37+
let node = {
38+
layoutInfo: loader,
39+
validRect: loader.rect
40+
};
41+
nodes.push(node);
42+
this.layoutNodes.set(loader.key, node);
43+
y = loader.rect.maxY;
44+
}
45+
46+
if (nodes.length === 0) {
47+
let rect = new Rect(0, y, this.virtualizer.visibleRect.width, this.placeholderHeight ?? this.virtualizer.visibleRect.height);
48+
let placeholder = new LayoutInfo('placeholder', 'placeholder', rect);
49+
let node = {
50+
layoutInfo: placeholder,
51+
validRect: placeholder.rect
52+
};
53+
nodes.push(node);
54+
this.layoutNodes.set(placeholder.key, node);
55+
y = placeholder.rect.maxY;
56+
}
57+
58+
this.contentSize.height = y + this.padding;
59+
return nodes;
60+
}
61+
62+
protected buildSection(node: Node<T>, x: number, y: number): LayoutNode {
63+
// Synthesize a collection node for the header.
64+
let headerNode = {
65+
type: 'header',
66+
key: node.key + ':header',
67+
parentKey: node.key,
68+
value: null,
69+
level: node.level,
70+
hasChildNodes: false,
71+
childNodes: [],
72+
rendered: node.rendered,
73+
textValue: node.textValue
74+
};
75+
76+
// Build layout node for it and adjust y offset of section children.
77+
let header = this.buildSectionHeader(headerNode, x, y);
78+
header.node = headerNode;
79+
header.layoutInfo.parentKey = node.key;
80+
this.layoutNodes.set(headerNode.key, header);
81+
y += header.layoutInfo.rect.height;
82+
83+
let section = super.buildSection(node, x, y);
84+
section.children.unshift(header);
85+
return section;
86+
}
87+
}

0 commit comments

Comments
 (0)