diff --git a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx index 5e60d7e9122..d0bc2c9687a 100644 --- a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx +++ b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx @@ -137,10 +137,7 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) { let layout = React.useMemo(() => new ListLayout({ - estimatedRowHeight: 32, - padding: 8, - loaderHeight: 40, - placeholderHeight: 32 + estimatedRowHeight: 32 }) , []); diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index df7b092ad51..2c29848d61b 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared'; +import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Rect, Size} from '@react-types/shared'; import {DOMLayoutDelegate} from '@react-aria/selection'; import {getChildNodes, getFirstItem, getLastItem, getNthItem} from '@react-stately/collections'; import {GridCollection} from '@react-types/grid'; @@ -24,6 +24,8 @@ export interface GridKeyboardDelegateOptions { direction: Direction, collator?: Intl.Collator, layoutDelegate?: LayoutDelegate, + /** @deprecated - Use layoutDelegate instead. */ + layout?: DeprecatedLayout, focusMode?: 'row' | 'cell' } @@ -42,7 +44,7 @@ export class GridKeyboardDelegate> implements Key this.disabledBehavior = options.disabledBehavior || 'all'; this.direction = options.direction; this.collator = options.collator; - this.layoutDelegate = options.layoutDelegate || new DOMLayoutDelegate(options.ref); + this.layoutDelegate = options.layoutDelegate || (options.layout ? new DeprecatedLayoutDelegate(options.layout) : new DOMLayoutDelegate(options.ref)); this.focusMode = options.focusMode || 'row'; } @@ -356,3 +358,38 @@ export class GridKeyboardDelegate> implements Key return null; } } + +/* Backward compatibility for old Virtualizer Layout interface. */ +interface DeprecatedLayout { + getLayoutInfo(key: Key): DeprecatedLayoutInfo, + getContentSize(): Size, + virtualizer: DeprecatedVirtualizer +} + +interface DeprecatedLayoutInfo { + rect: Rect +} + +interface DeprecatedVirtualizer { + visibleRect: Rect +} + +class DeprecatedLayoutDelegate implements LayoutDelegate { + layout: DeprecatedLayout; + + constructor(layout: DeprecatedLayout) { + this.layout = layout; + } + + getContentSize(): Size { + return this.layout.getContentSize(); + } + + getItemRect(key: Key): Rect | null { + return this.layout.getLayoutInfo(key)?.rect || null; + } + + getVisibleRect(): Rect { + return this.layout.virtualizer.visibleRect; + } +} diff --git a/packages/@react-aria/table/src/useTable.ts b/packages/@react-aria/table/src/useTable.ts index f484759c8d9..df1ede95a13 100644 --- a/packages/@react-aria/table/src/useTable.ts +++ b/packages/@react-aria/table/src/useTable.ts @@ -15,7 +15,7 @@ import {GridAria, GridProps, useGrid} from '@react-aria/grid'; import {gridIds} from './utils'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {LayoutDelegate} from '@react-types/shared'; +import {Key, LayoutDelegate, Rect, Size} from '@react-types/shared'; import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils'; import {RefObject, useMemo} from 'react'; import {TableKeyboardDelegate} from './TableKeyboardDelegate'; @@ -25,7 +25,23 @@ import {useCollator, useLocale, useLocalizedStringFormatter} from '@react-aria/i export interface AriaTableProps extends GridProps { /** The layout object for the table. Computes what content is visible and how to position and style them. */ - layoutDelegate?: LayoutDelegate + layoutDelegate?: LayoutDelegate, + /** @deprecated - Use layoutDelegate instead. */ + layout?: DeprecatedLayout +} + +interface DeprecatedLayout { + getLayoutInfo(key: Key): DeprecatedLayoutInfo, + getContentSize(): Size, + virtualizer: DeprecatedVirtualizer +} + +interface DeprecatedLayoutInfo { + rect: Rect +} + +interface DeprecatedVirtualizer { + visibleRect: Rect } /** @@ -40,7 +56,8 @@ export function useTable(props: AriaTableProps, state: TableState | TreeGr let { keyboardDelegate, isVirtualized, - layoutDelegate + layoutDelegate, + layout } = props; // 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(props: AriaTableProps, state: TableState | TreeGr ref, direction, collator, - layoutDelegate - }), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate]); + layoutDelegate, + layout + }), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate, layout]); let id = useId(props.id); gridIds.set(state, id); diff --git a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx index 6fbd5ea0007..97f84055da8 100644 --- a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx +++ b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx @@ -74,9 +74,7 @@ export function layoutInfoToStyle(layoutInfo: LayoutInfo, dir: Direction, parent position: layoutInfo.isSticky ? 'sticky' : 'absolute', // Sticky elements are positioned in normal document flow. Display inline-block so that they don't push other sticky columns onto the following rows. display: layoutInfo.isSticky ? 'inline-block' : undefined, - // Use clip instead of hidden to avoid creating an implicit generic container in the accessibility tree in Firefox. - // Hidden still allows programmatic scrolling whereas clip does not. - overflow: layoutInfo.allowOverflow ? 'visible' : 'clip', + overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden', opacity: layoutInfo.opacity, zIndex: layoutInfo.zIndex, transform: layoutInfo.transform, diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index c185e144d0c..8ef8b990161 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -54,6 +54,7 @@ "@react-stately/collections": "^3.10.7", "@react-stately/layout": "^3.13.9", "@react-stately/list": "^3.10.5", + "@react-stately/virtualizer": "^3.7.1", "@react-types/grid": "^3.2.6", "@react-types/shared": "^3.23.1", "@spectrum-icons/ui": "^3.6.7", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index c8a08716690..a776c299e7f 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -22,10 +22,10 @@ import InsertionIndicator from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ListKeyboardDelegate} from '@react-aria/selection'; -import {ListLayout} from '@react-stately/layout'; import {ListState, useListState} from '@react-stately/list'; import listStyles from './styles.css'; import {ListViewItem} from './ListViewItem'; +import {ListViewLayout} from './ListViewLayout'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {JSX, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import RootDropIndicator from './RootDropIndicator'; @@ -70,7 +70,7 @@ interface ListViewContextValue { onAction:(key: Key) => void, isListDraggable: boolean, isListDroppable: boolean, - layout: ListLayout, + layout: ListViewLayout, loadingState: LoadingState, renderEmptyState?: () => JSX.Element } @@ -94,16 +94,12 @@ const ROW_HEIGHTS = { function useListLayout(state: ListState, density: SpectrumListViewProps['density'], overflowMode: SpectrumListViewProps['overflowMode']) { let {scale} = useProvider(); - let isEmpty = state.collection.size === 0; let layout = useMemo(() => - new ListLayout({ - estimatedRowHeight: ROW_HEIGHTS[density][scale], - padding: 0, - loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale], - enableEmptyState: true + new ListViewLayout({ + estimatedRowHeight: ROW_HEIGHTS[density][scale] }) // eslint-disable-next-line react-hooks/exhaustive-deps - , [scale, density, isEmpty, overflowMode]); + , [scale, density, overflowMode]); return layout; } diff --git a/packages/@react-spectrum/list/src/ListViewLayout.ts b/packages/@react-spectrum/list/src/ListViewLayout.ts new file mode 100644 index 00000000000..cddc1936d4f --- /dev/null +++ b/packages/@react-spectrum/list/src/ListViewLayout.ts @@ -0,0 +1,55 @@ +import {InvalidationContext, LayoutInfo, Rect} from '@react-stately/virtualizer'; +import {LayoutNode, ListLayout} from '@react-stately/layout'; +import {Node} from '@react-types/shared'; + +interface ListViewLayoutProps { + isLoading?: boolean +} + +export class ListViewLayout extends ListLayout { + private isLoading: boolean = false; + + validate(invalidationContext: InvalidationContext): void { + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; + super.validate(invalidationContext); + } + + protected buildCollection(): LayoutNode[] { + let nodes = super.buildCollection(); + let y = this.contentSize.height; + + if (this.isLoading) { + let rect = new Rect(0, y, this.virtualizer.visibleRect.width, nodes.length === 0 ? this.virtualizer.visibleRect.height : this.estimatedRowHeight); + let loader = new LayoutInfo('loader', 'loader', rect); + let node = { + layoutInfo: loader, + validRect: loader.rect + }; + nodes.push(node); + this.layoutNodes.set(loader.key, node); + y = loader.rect.maxY; + } + + if (nodes.length === 0) { + let rect = new Rect(0, y, this.virtualizer.visibleRect.width, this.virtualizer.visibleRect.height); + let placeholder = new LayoutInfo('placeholder', 'placeholder', rect); + let node = { + layoutInfo: placeholder, + validRect: placeholder.rect + }; + nodes.push(node); + this.layoutNodes.set(placeholder.key, node); + y = placeholder.rect.maxY; + } + + this.contentSize.height = y; + return nodes; + } + + protected buildItem(node: Node, x: number, y: number): LayoutNode { + let res = super.buildItem(node, x, y); + // allow overflow so the focus ring/selection ring can extend outside to overlap with the adjacent items borders + res.layoutInfo.allowOverflow = true; + return res; + } +} diff --git a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx index 240b0b8c076..b89ecb13b8f 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx @@ -17,9 +17,9 @@ import {FocusScope} from '@react-aria/focus'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ListBoxContext} from './ListBoxContext'; +import {ListBoxLayout} from './ListBoxLayout'; import {ListBoxOption} from './ListBoxOption'; import {ListBoxSection} from './ListBoxSection'; -import {ListLayout} from '@react-stately/layout'; import {ListState} from '@react-stately/list'; import {mergeProps} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; @@ -31,7 +31,7 @@ import {useProvider} from '@react-spectrum/provider'; import {Virtualizer, VirtualizerItem} from '@react-aria/virtualizer'; interface ListBoxBaseProps extends AriaListBoxOptions, DOMProps, AriaLabelingProps, StyleProps { - layout: ListLayout, + layout: ListBoxLayout, state: ListState, autoFocus?: boolean | FocusStrategy, shouldFocusWrap?: boolean, @@ -48,17 +48,14 @@ interface ListBoxBaseProps extends AriaListBoxOptions, DOMProps, AriaLabel } /** @private */ -export function useListBoxLayout(): ListLayout { +export function useListBoxLayout(): ListBoxLayout { let {scale} = useProvider(); let layout = useMemo(() => - new ListLayout({ + new ListBoxLayout({ estimatedRowHeight: scale === 'large' ? 48 : 32, estimatedHeadingHeight: scale === 'large' ? 33 : 26, padding: scale === 'large' ? 5 : 4, // TODO: get from DNA - loaderHeight: 40, - placeholderHeight: scale === 'large' ? 48 : 32, - forceSectionHeaders: true, - enableEmptyState: true + placeholderHeight: scale === 'large' ? 48 : 32 }) , [scale]); diff --git a/packages/@react-spectrum/listbox/src/ListBoxLayout.ts b/packages/@react-spectrum/listbox/src/ListBoxLayout.ts new file mode 100644 index 00000000000..6764b869b77 --- /dev/null +++ b/packages/@react-spectrum/listbox/src/ListBoxLayout.ts @@ -0,0 +1,87 @@ +import {InvalidationContext, LayoutInfo, Rect} from '@react-stately/virtualizer'; +import {LayoutNode, ListLayout, ListLayoutOptions} from '@react-stately/layout'; +import {Node} from '@react-types/shared'; + +interface ListBoxLayoutProps { + isLoading?: boolean +} + +interface ListBoxLayoutOptions extends ListLayoutOptions { + placeholderHeight: number, + padding: number +} + +export class ListBoxLayout extends ListLayout { + private isLoading: boolean = false; + private placeholderHeight: number; + private padding: number; + + constructor(opts: ListBoxLayoutOptions) { + super(opts); + this.placeholderHeight = opts.placeholderHeight; + this.padding = opts.padding; + } + + validate(invalidationContext: InvalidationContext): void { + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; + super.validate(invalidationContext); + } + + protected buildCollection(): LayoutNode[] { + let nodes = super.buildCollection(this.padding); + let y = this.contentSize.height; + + if (this.isLoading) { + let rect = new Rect(0, y, this.virtualizer.visibleRect.width, 40); + let loader = new LayoutInfo('loader', 'loader', rect); + let node = { + layoutInfo: loader, + validRect: loader.rect + }; + nodes.push(node); + this.layoutNodes.set(loader.key, node); + y = loader.rect.maxY; + } + + if (nodes.length === 0) { + let rect = new Rect(0, y, this.virtualizer.visibleRect.width, this.placeholderHeight ?? this.virtualizer.visibleRect.height); + let placeholder = new LayoutInfo('placeholder', 'placeholder', rect); + let node = { + layoutInfo: placeholder, + validRect: placeholder.rect + }; + nodes.push(node); + this.layoutNodes.set(placeholder.key, node); + y = placeholder.rect.maxY; + } + + this.contentSize.height = y + this.padding; + return nodes; + } + + protected buildSection(node: Node, x: number, y: number): LayoutNode { + // Synthesize a collection node for the header. + let headerNode = { + type: 'header', + key: node.key + ':header', + parentKey: node.key, + value: null, + level: node.level, + hasChildNodes: false, + childNodes: [], + rendered: node.rendered, + textValue: node.textValue + }; + + // Build layout node for it and adjust y offset of section children. + let header = this.buildSectionHeader(headerNode, x, y); + header.node = headerNode; + header.layoutInfo.parentKey = node.key; + this.layoutNodes.set(headerNode.key, header); + y += header.layoutInfo.rect.height; + + let section = super.buildSection(node, x, y); + section.children.unshift(header); + return section; + } +} diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index ad240fcd9f6..9a49f6ed5b9 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -48,8 +48,8 @@ import {DragPreview as SpectrumDragPreview} from './DragPreview'; import {SpectrumTableProps} from './TableViewWrapper'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; import stylesOverrides from './table.css'; -import {TableLayout} from '@react-stately/layout'; import {TableState, TreeGridState, useTableColumnResizeState} from '@react-stately/table'; +import {TableViewLayout} from './TableViewLayout'; import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; import {useButton} from '@react-aria/button'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -113,7 +113,7 @@ export interface TableContextValue { dragAndDropHooks: DragAndDropHooks['dragAndDropHooks'], isTableDraggable: boolean, isTableDroppable: boolean, - layout: TableLayout, + layout: TableViewLayout, headerRowHovered: boolean, isInResizeMode: boolean, setIsInResizeMode: (val: boolean) => void, @@ -185,7 +185,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(undefined); let density = props.density || 'regular'; - let layout = useMemo(() => new TableLayout({ + let layout = useMemo(() => new TableViewLayout({ // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. rowHeight: props.overflowMode === 'wrap' ? null @@ -198,9 +198,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(props: TableBaseProps, ref: DOMRef extends HTMLAttributes { tableState: TableState, - layout: TableLayout, + layout: TableViewLayout, collection: TableCollection, focusedKey: Key | null, renderView: (type: string, content: GridNode) => ReactElement, diff --git a/packages/@react-spectrum/table/src/TableViewLayout.ts b/packages/@react-spectrum/table/src/TableViewLayout.ts new file mode 100644 index 00000000000..5b112d0c0bd --- /dev/null +++ b/packages/@react-spectrum/table/src/TableViewLayout.ts @@ -0,0 +1,69 @@ +import {GridNode} from '@react-types/grid'; +import {LayoutInfo, Rect} from '@react-stately/virtualizer'; +import {LayoutNode, TableLayout} from '@react-stately/layout'; + +export class TableViewLayout extends TableLayout { + private isLoading: boolean = false; + + protected buildCollection(): LayoutNode[] { + let loadingState = this.collection.body.props.loadingState; + this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; + return super.buildCollection(); + } + + protected buildColumn(node: GridNode, x: number, y: number): LayoutNode { + let res = super.buildColumn(node, x, y); + res.layoutInfo.allowOverflow = true; // for resizer nubbin + return res; + } + + protected buildBody(): LayoutNode { + let node = super.buildBody(0); + let {children, layoutInfo} = node; + let width = node.layoutInfo.rect.width; + + if (this.isLoading) { + // Add some margin around the loader to ensure that scrollbars don't flicker in and out. + let rect = new Rect(40, 40, (width || this.virtualizer.visibleRect.width) - 80, children.length === 0 ? this.virtualizer.visibleRect.height - 80 : 60); + let loader = new LayoutInfo('loader', 'loader', rect); + loader.parentKey = layoutInfo.key; + loader.isSticky = children.length === 0; + let node = { + layoutInfo: loader, + validRect: loader.rect + }; + children.push(node); + this.layoutNodes.set(loader.key, node); + layoutInfo.rect.height = loader.rect.maxY; + width = Math.max(width, rect.width); + } else if (children.length === 0) { + let rect = new Rect(40, 40, this.virtualizer.visibleRect.width - 80, this.virtualizer.visibleRect.height - 80); + let empty = new LayoutInfo('empty', 'empty', rect); + empty.parentKey = layoutInfo.key; + empty.isSticky = true; + let node = { + layoutInfo: empty, + validRect: empty.rect + }; + children.push(node); + layoutInfo.rect.height = empty.rect.maxY; + width = Math.max(width, rect.width); + } + + return node; + } + + protected buildRow(node: GridNode, x: number, y: number): LayoutNode { + let res = super.buildRow(node, x, y); + res.layoutInfo.rect.height += 1; // for bottom border + return res; + } + + protected getEstimatedRowHeight(): number { + return super.getEstimatedRowHeight() + 1; // for bottom border + } + + protected isStickyColumn(node: GridNode) { + return node.props?.isDragButtonCell || node.props?.isSelectionCell; + } +} diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6058d5b2848..e2bf838f6c1 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -14,66 +14,48 @@ import {Collection, DropTarget, DropTargetDelegate, Key, Node} from '@react-type import {getChildNodes} from '@react-stately/collections'; import {InvalidationContext, Layout, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; -export type ListLayoutOptions = { - /** The height of a row in px. */ +export interface ListLayoutOptions { + /** The fixed height of a row in px. */ rowHeight?: number, + /** The estimated height of a row, when row heights are variable. */ estimatedRowHeight?: number, + /** The fixed height of a section header in px. */ headingHeight?: number, - estimatedHeadingHeight?: number, - padding?: number, - indentationForItem?: (collection: Collection>, key: Key) => number, - loaderHeight?: number, - placeholderHeight?: number, - forceSectionHeaders?: boolean, - enableEmptyState?: boolean -}; + /** The estimated height of a section header, when the height is variable. */ + estimatedHeadingHeight?: number +} // A wrapper around LayoutInfo that supports hierarchy export interface LayoutNode { node?: Node, layoutInfo: LayoutInfo, - header?: LayoutInfo, children?: LayoutNode[], validRect: Rect, index?: number } -export interface ListLayoutProps { - isLoading?: boolean -} - const DEFAULT_HEIGHT = 48; /** - * The ListLayout class is an implementation of a virtualizer {@link Layout} - * it is used for creating lists and lists with indented sub-lists. - * + * The ListLayout class is an implementation of a virtualizer {@link Layout}. * To configure a ListLayout, you can use the properties to define the * layouts and/or use the method for defining indentation. * The {@link ListKeyboardDelegate} extends the existing virtualizer * delegate with an additional method to do this (it uses the same delegate object as * the virtualizer itself). */ -export class ListLayout extends Layout, ListLayoutProps> implements DropTargetDelegate { +export class ListLayout extends Layout, O> implements DropTargetDelegate { protected rowHeight: number; protected estimatedRowHeight: number; protected headingHeight: number; protected estimatedHeadingHeight: number; - protected forceSectionHeaders: boolean; - protected padding: number; - protected indentationForItem?: (collection: Collection>, key: Key) => number; - protected layoutInfos: Map; protected layoutNodes: Map; protected contentSize: Size; protected collection: Collection>; - protected isLoading: boolean; - protected lastWidth: number; - protected lastCollection: Collection>; + private lastCollection: Collection>; + private lastWidth: number; protected rootNodes: LayoutNode[]; - protected invalidateEverything: boolean; - protected loaderHeight: number; - protected placeholderHeight: number; - protected enableEmptyState: boolean; + private invalidateEverything: boolean; /** The rectangle containing currently valid layout infos. */ protected validRect: Rect; /** The rectangle of requested layout infos so far. */ @@ -83,19 +65,12 @@ export class ListLayout extends Layout, ListLayoutProps> implements D * Creates a new ListLayout with options. See the list of properties below for a description * of the options that can be provided. */ - constructor(options: ListLayoutOptions = {}) { + constructor(options: ListLayoutOptions = {}) { super(); this.rowHeight = options.rowHeight; this.estimatedRowHeight = options.estimatedRowHeight; this.headingHeight = options.headingHeight; this.estimatedHeadingHeight = options.estimatedHeadingHeight; - this.forceSectionHeaders = options.forceSectionHeaders; - this.padding = options.padding || 0; - this.indentationForItem = options.indentationForItem; - this.loaderHeight = options.loaderHeight; - this.placeholderHeight = options.placeholderHeight; - this.enableEmptyState = options.enableEmptyState || false; - this.layoutInfos = new Map(); this.layoutNodes = new Map(); this.rootNodes = []; this.lastWidth = 0; @@ -107,7 +82,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D getLayoutInfo(key: Key) { this.ensureLayoutInfo(key); - return this.layoutInfos.get(key)!; + return this.layoutNodes.get(key)?.layoutInfo || null; } getVisibleLayoutInfos(rect: Rect) { @@ -129,9 +104,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements D for (let node of nodes) { if (this.isVisible(node, rect)) { res.push(node.layoutInfo); - if (node.header) { - res.push(node.header); - } if (node.children) { addNodes(node.children); @@ -166,7 +138,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D // If the layout info wasn't found, it might be outside the bounds of the area that we've // computed layout for so far. This can happen when accessing a random key, e.g pressing Home/End. // Compute the full layout and try again. - if (!this.layoutInfos.has(key) && this.requestedRect.area < this.contentSize.area && this.lastCollection) { + if (!this.layoutNodes.has(key) && this.requestedRect.area < this.contentSize.area && this.lastCollection) { this.requestedRect = new Rect(0, 0, Infinity, Infinity); this.rootNodes = this.buildCollection(); this.requestedRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height); @@ -176,19 +148,18 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return false; } - private isVisible(node: LayoutNode, rect: Rect) { - return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || this.virtualizer.isPersistedKey(node.layoutInfo.key); + protected isVisible(node: LayoutNode, rect: Rect) { + return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || node.layoutInfo.type === 'header' || this.virtualizer.isPersistedKey(node.layoutInfo.key); } - protected shouldInvalidateEverything(invalidationContext: InvalidationContext) { + protected shouldInvalidateEverything(invalidationContext: InvalidationContext) { // Invalidate cache if the size of the collection changed. // In this case, we need to recalculate the entire layout. return invalidationContext.sizeChanged; } - validate(invalidationContext: InvalidationContext) { + validate(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection; - this.isLoading = invalidationContext.layoutOptions?.isLoading || false; // Reset valid rect if we will have to invalidate everything. // Otherwise we can reuse cached layout infos outside the current visible rect. @@ -205,8 +176,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements D if (!this.collection.getItem(key)) { let layoutNode = this.layoutNodes.get(key); if (layoutNode) { - this.layoutInfos.delete(layoutNode.layoutInfo.key); - this.layoutInfos.delete(layoutNode.header?.key); this.layoutNodes.delete(key); } } @@ -219,12 +188,11 @@ export class ListLayout extends Layout, ListLayoutProps> implements D this.validRect = this.requestedRect.copy(); } - protected buildCollection(): LayoutNode[] { - let y = this.padding; + protected buildCollection(y = 0): LayoutNode[] { let skipped = 0; let nodes = []; for (let node of this.collection) { - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight); + let rowHeight = this.rowHeight ?? this.estimatedRowHeight; // Skip rows before the valid rectangle unless they are already cached. if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { @@ -243,25 +211,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D } } - if (this.isLoading) { - let rect = new Rect(0, y, this.virtualizer.visibleRect.width, - this.loaderHeight ?? this.virtualizer.visibleRect.height); - let loader = new LayoutInfo('loader', 'loader', rect); - this.layoutInfos.set('loader', loader); - nodes.push({layoutInfo: loader}); - y = loader.rect.maxY; - } - - if (nodes.length === 0 && this.enableEmptyState) { - let rect = new Rect(0, y, this.virtualizer.visibleRect.width, - this.placeholderHeight ?? this.virtualizer.visibleRect.height); - let placeholder = new LayoutInfo('placeholder', 'placeholder', rect); - this.layoutInfos.set('placeholder', placeholder); - nodes.push({layoutInfo: placeholder}); - y = placeholder.rect.maxY; - } - - this.contentSize = new Size(this.virtualizer.visibleRect.width, y + this.padding); + this.contentSize = new Size(this.virtualizer.visibleRect.width, y); return nodes; } @@ -271,7 +221,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D !this.invalidateEverything && cached && cached.node === node && - y === (cached.header || cached.layoutInfo).rect.y && + y === cached.layoutInfo.rect.y && cached.layoutInfo.rect.intersects(this.validRect) && cached.validRect.containsRect(cached.layoutInfo.rect.intersection(this.requestedRect)) ); @@ -286,11 +236,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements D layoutNode.node = node; layoutNode.layoutInfo.parentKey = parentKey ?? null; - this.layoutInfos.set(layoutNode.layoutInfo.key, layoutNode.layoutInfo); - if (layoutNode.header) { - this.layoutInfos.set(layoutNode.header.key, layoutNode.header); - } - this.layoutNodes.set(node.key, layoutNode); return layoutNode; } @@ -306,17 +251,8 @@ export class ListLayout extends Layout, ListLayoutProps> implements D } } - private buildSection(node: Node, x: number, y: number): LayoutNode { + protected buildSection(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; - let header = null; - if (node.rendered || this.forceSectionHeaders) { - let headerNode = this.buildSectionHeader(node, x, y); - header = headerNode.layoutInfo; - header.key += ':header'; - header.parentKey = node.key; - y += header.rect.height; - } - let rect = new Rect(0, y, width, 0); let layoutInfo = new LayoutInfo(node.type, node.key, rect); @@ -347,14 +283,13 @@ export class ListLayout extends Layout, ListLayoutProps> implements D rect.height = y - startY; return { - header, layoutInfo, children, validRect: layoutInfo.rect.intersection(this.requestedRect) }; } - private buildSectionHeader(node: Node, x: number, y: number): LayoutNode { + protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; let rectHeight = this.headingHeight; let isEstimated = false; @@ -365,7 +300,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D // Mark as estimated if the size of the overall virtualizer changed, // or the content of the item changed. let previousLayoutNode = this.layoutNodes.get(node.key); - let previousLayoutInfo = previousLayoutNode?.header || previousLayoutNode?.layoutInfo; + let previousLayoutInfo = previousLayoutNode?.layoutInfo; if (previousLayoutInfo) { let curNode = this.collection.getItem(node.key); let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null; @@ -391,7 +326,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D }; } - private buildItem(node: Node, x: number, y: number): LayoutNode { + protected buildItem(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; let rectHeight = this.rowHeight; let isEstimated = false; @@ -415,14 +350,8 @@ export class ListLayout extends Layout, ListLayoutProps> implements D rectHeight = DEFAULT_HEIGHT; } - if (typeof this.indentationForItem === 'function') { - x += this.indentationForItem(this.collection, node.key) || 0; - } - let rect = new Rect(x, y, width - x, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); - // allow overflow so the focus ring/selection ring can extend outside to overlap with the adjacent items borders - layoutInfo.allowOverflow = true; layoutInfo.estimatedSize = isEstimated; return { layoutInfo, @@ -431,18 +360,19 @@ export class ListLayout extends Layout, ListLayoutProps> implements D } updateItemSize(key: Key, size: Size) { - let layoutInfo = this.layoutInfos.get(key); + let layoutNode = this.layoutNodes.get(key); // If no layoutInfo, item has been deleted/removed. - if (!layoutInfo) { + if (!layoutNode) { return false; } + let layoutInfo = layoutNode.layoutInfo; layoutInfo.estimatedSize = false; if (layoutInfo.rect.height !== size.height) { // Copy layout info rather than mutating so that later caches are invalidated. let newLayoutInfo = layoutInfo.copy(); newLayoutInfo.rect.height = size.height; - this.layoutInfos.set(key, newLayoutInfo); + layoutNode.layoutInfo = newLayoutInfo; // Items after this layoutInfo will need to be repositioned to account for the new height. // Adjust the validRect so that only items above remain valid. @@ -473,9 +403,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D n.validRect = n.validRect.intersection(this.validRect); // Replace layout info in LayoutNode - if (n.header === oldLayoutInfo) { - n.header = newLayoutInfo; - } else if (n.layoutInfo === oldLayoutInfo) { + if (n.layoutInfo === oldLayoutInfo) { n.layoutInfo = newLayoutInfo; } } diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index b5a92e9e222..2ed3de631b5 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -14,34 +14,24 @@ import {DropTarget, Key} from '@react-types/shared'; import {getChildNodes} from '@react-stately/collections'; import {GridNode} from '@react-types/grid'; import {InvalidationContext, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; -import {LayoutNode, ListLayout, ListLayoutOptions, ListLayoutProps} from './ListLayout'; +import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; import {TableCollection} from '@react-types/table'; import {TableColumnLayout} from '@react-stately/table'; -export interface TableLayoutOptions extends ListLayoutOptions { - scrollContainer?: 'table' | 'body' -} - -export interface TableLayoutProps extends ListLayoutProps { +export interface TableLayoutProps { columnWidths?: Map } -export class TableLayout extends ListLayout { - collection: TableCollection; - lastCollection: TableCollection; - columnWidths: Map; - stickyColumnIndices: number[]; - isLoading = false; - lastPersistedKeys: Set = null; - persistedIndices: Map = new Map(); - scrollContainer: 'table' | 'body'; - private disableSticky: boolean; - - constructor(options: TableLayoutOptions) { +export class TableLayout extends ListLayout { + protected collection: TableCollection; + private columnWidths: Map; + private stickyColumnIndices: number[]; + private lastPersistedKeys: Set = null; + private persistedIndices: Map = new Map(); + + constructor(options: ListLayoutOptions) { super(options); - this.scrollContainer = options.scrollContainer || 'table'; this.stickyColumnIndices = []; - this.disableSticky = this.checkChrome105(); } private columnsChanged(newCollection: TableCollection, oldCollection: TableCollection | null) { @@ -56,7 +46,7 @@ export class TableLayout extends ListLayout { ); } - validate(invalidationContext: InvalidationContext): void { + validate(invalidationContext: InvalidationContext): void { let newCollection = this.virtualizer.collection as TableCollection; // If columnWidths were provided via layoutOptions, update those. @@ -76,21 +66,19 @@ export class TableLayout extends ListLayout { } protected buildCollection(): LayoutNode[] { - // Track whether we were previously loading. This is used to adjust the animations of async loading vs inserts. - let loadingState = this.collection.body.props.loadingState; - this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; this.stickyColumnIndices = []; for (let column of this.collection.columns) { // The selection cell and any other sticky columns always need to be visible. // In addition, row headers need to be in the DOM for accessibility labeling. - if (column.props.isDragButtonCell || column.props.isSelectionCell || this.collection.rowHeaderColumnKeys.has(column.key)) { + if (this.isStickyColumn(column) || this.collection.rowHeaderColumnKeys.has(column.key)) { this.stickyColumnIndices.push(column.index); } } - let header = this.buildColumnHeader(); - let body = this.buildBody(this.scrollContainer === 'body' ? 0 : header.layoutInfo.rect.height); + let header = this.buildTableHeader(); + this.layoutNodes.set(header.layoutInfo.key, header); + let body = this.buildBody(header.layoutInfo.rect.height); this.lastPersistedKeys = null; body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); @@ -101,7 +89,7 @@ export class TableLayout extends ListLayout { ]; } - private buildColumnHeader(): LayoutNode { + protected buildTableHeader(): LayoutNode { let rect = new Rect(0, 0, 0, 0); let layoutInfo = new LayoutInfo('header', this.collection.head?.key ?? 'header', rect); layoutInfo.isSticky = true; @@ -122,8 +110,6 @@ export class TableLayout extends ListLayout { rect.width = width; rect.height = y; - this.layoutInfos.set(layoutInfo.key, layoutInfo); - return { layoutInfo, children, @@ -131,7 +117,7 @@ export class TableLayout extends ListLayout { }; } - private buildHeaderRow(headerRow: GridNode, x: number, y: number): LayoutNode { + protected buildHeaderRow(headerRow: GridNode, x: number, y: number): LayoutNode { let rect = new Rect(0, y, 0, 0); let row = new LayoutInfo('headerrow', headerRow.key, rect); @@ -166,8 +152,6 @@ export class TableLayout extends ListLayout { if (child.layoutInfo.rect.height !== height) { // Need to copy the layout info before we mutate it. child.layoutInfo = child.layoutInfo.copy(); - this.layoutInfos.set(child.layoutInfo.key, child.layoutInfo); - child.layoutInfo.rect.height = height; } } @@ -209,15 +193,18 @@ export class TableLayout extends ListLayout { return {height, isEstimated}; } - private buildColumn(node: GridNode, x: number, y: number): LayoutNode { + protected getEstimatedRowHeight(): number { + return this.rowHeight ?? this.estimatedRowHeight; + } + + protected buildColumn(node: GridNode, x: number, y: number): LayoutNode { let width = this.getRenderedColumnWidth(node); let {height, isEstimated} = this.getEstimatedHeight(node, width, this.headingHeight, this.estimatedHeadingHeight); let rect = new Rect(x, y, width, height); let layoutInfo = new LayoutInfo(node.type, node.key, rect); - layoutInfo.isSticky = !this.disableSticky && (node.props?.isDragButtonCell || node.props?.isSelectionCell); + layoutInfo.isSticky = this.isStickyColumn(node); layoutInfo.zIndex = layoutInfo.isSticky ? 2 : 1; layoutInfo.estimatedSize = isEstimated; - layoutInfo.allowOverflow = true; return { layoutInfo, @@ -225,7 +212,13 @@ export class TableLayout extends ListLayout { }; } - private buildBody(y: number): LayoutNode { + // For subclasses. + // eslint-disable-next-line + protected isStickyColumn(node: GridNode) { + return false; + } + + protected buildBody(y: number): LayoutNode { let rect = new Rect(0, y, 0, 0); let layoutInfo = new LayoutInfo('rowgroup', this.collection.body.key, rect); @@ -233,9 +226,8 @@ export class TableLayout extends ListLayout { let skipped = 0; let width = 0; let children: LayoutNode[] = []; + let rowHeight = this.getEstimatedRowHeight(); for (let [i, node] of [...getChildNodes(this.collection.body, this.collection)].entries()) { - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight) + 1; - // Skip rows before the valid rectangle unless they are already cached. if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { y += rowHeight; @@ -257,36 +249,13 @@ export class TableLayout extends ListLayout { } } - if (this.isLoading) { - // Add some margin around the loader to ensure that scrollbars don't flicker in and out. - let rect = new Rect(40, Math.max(y, 40), (width || this.virtualizer.visibleRect.width) - 80, children.length === 0 ? this.virtualizer.visibleRect.height - 80 : 60); - let loader = new LayoutInfo('loader', 'loader', rect); - loader.parentKey = layoutInfo.key; - loader.isSticky = !this.disableSticky && children.length === 0; - this.layoutInfos.set('loader', loader); - children.push({layoutInfo: loader, validRect: loader.rect}); - y = loader.rect.maxY; - width = Math.max(width, rect.width); - } else if (children.length === 0) { - if (this.enableEmptyState) { - let rect = new Rect(40, Math.max(y, 40), this.virtualizer.visibleRect.width - 80, this.virtualizer.visibleRect.height - 80); - let empty = new LayoutInfo('empty', 'empty', rect); - empty.parentKey = layoutInfo.key; - empty.isSticky = !this.disableSticky; - this.layoutInfos.set('empty', empty); - children.push({layoutInfo: empty, validRect: empty.rect}); - y = empty.rect.maxY; - width = Math.max(width, rect.width); - } else { - y = this.virtualizer.visibleRect.maxY; - } + if (children.length === 0) { + y = this.virtualizer.visibleRect.maxY; } rect.width = width; rect.height = y - startY; - this.layoutInfos.set(layoutInfo.key, layoutInfo); - return { layoutInfo, children, @@ -310,7 +279,7 @@ export class TableLayout extends ListLayout { } } - private buildRow(node: GridNode, x: number, y: number): LayoutNode { + protected buildRow(node: GridNode, x: number, y: number): LayoutNode { let rect = new Rect(x, y, 0, 0); let layoutInfo = new LayoutInfo('row', node.key, rect); @@ -338,8 +307,8 @@ export class TableLayout extends ListLayout { this.setChildHeights(children, height); - rect.width = this.layoutInfos.get(this.collection.head?.key ?? 'header').rect.width; - rect.height = height + 1; // +1 for bottom border + rect.width = this.layoutNodes.get(this.collection.head?.key ?? 'header').layoutInfo.rect.width; + rect.height = height; return { layoutInfo, @@ -348,12 +317,12 @@ export class TableLayout extends ListLayout { }; } - private buildCell(node: GridNode, x: number, y: number): LayoutNode { + protected buildCell(node: GridNode, x: number, y: number): LayoutNode { let width = this.getRenderedColumnWidth(node); let {height, isEstimated} = this.getEstimatedHeight(node, width, this.rowHeight, this.estimatedRowHeight); let rect = new Rect(x, y, width, height); let layoutInfo = new LayoutInfo(node.type, node.key, rect); - layoutInfo.isSticky = !this.disableSticky && (node.props?.isDragButtonCell || node.props?.isSelectionCell); + layoutInfo.isSticky = this.isStickyColumn(node); layoutInfo.zIndex = layoutInfo.isSticky ? 2 : 1; layoutInfo.estimatedSize = isEstimated; @@ -367,7 +336,7 @@ export class TableLayout extends ListLayout { // Adjust rect to keep number of visible rows consistent. // (only if height > 1 for getDropTargetFromPoint) if (rect.height > 1) { - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight) + 1; // +1 for border + let rowHeight = this.getEstimatedRowHeight(); rect.y = Math.floor(rect.y / rowHeight) * rowHeight; rect.height = Math.ceil(rect.height / rowHeight) * rowHeight; } @@ -508,7 +477,7 @@ export class TableLayout extends ListLayout { // Build a map of parentKey => indices of children to persist. for (let key of this.virtualizer.persistedKeys) { - let layoutInfo = this.layoutInfos.get(key); + let layoutInfo = this.layoutNodes.get(key)?.layoutInfo; // Walk up ancestors so parents are also persisted if children are. while (layoutInfo && layoutInfo.parentKey) { @@ -526,7 +495,7 @@ export class TableLayout extends ListLayout { indices.push(index); } - layoutInfo = this.layoutInfos.get(layoutInfo.parentKey); + layoutInfo = this.layoutNodes.get(layoutInfo.parentKey)?.layoutInfo; } } @@ -535,24 +504,6 @@ export class TableLayout extends ListLayout { } } - // Checks if Chrome version is 105 or greater - private checkChrome105() { - if (typeof window === 'undefined' || window.navigator == null) { - return false; - } - - let isChrome105; - if (window.navigator['userAgentData']) { - isChrome105 = window.navigator['userAgentData']?.brands.some(b => b.brand === 'Chromium' && Number(b.version) === 105); - } else { - let regex = /Chrome\/(\d+)/; - let matches = regex.exec(window.navigator.userAgent); - isChrome105 = matches && matches.length >= 2 && Number(matches[1]) === 105; - } - - return isChrome105; - } - getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { x += this.virtualizer.visibleRect.x; y += this.virtualizer.visibleRect.y; diff --git a/packages/@react-stately/layout/src/index.ts b/packages/@react-stately/layout/src/index.ts index f2cee0c4eff..bbfb764150a 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export type {ListLayoutOptions, ListLayoutProps, LayoutNode} from './ListLayout'; -export type {TableLayoutOptions, TableLayoutProps} from './TableLayout'; +export type {ListLayoutOptions, LayoutNode} from './ListLayout'; +export type {TableLayoutProps} from './TableLayout'; export {ListLayout} from './ListLayout'; export {TableLayout} from './TableLayout'; diff --git a/packages/@react-stately/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index 267e6625b23..27cad06e708 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -66,7 +66,7 @@ export abstract class Layout implements LayoutDelegat * Should be implemented by subclasses. * @param key The key of the LayoutInfo to retrieve. */ - abstract getLayoutInfo(key: Key): LayoutInfo; + abstract getLayoutInfo(key: Key): LayoutInfo | null; /** * Returns size of the content. By default, it returns collectionView's size. diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 87f9ac15ffb..9a5b058f10d 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -139,4 +139,4 @@ export type {DirectoryDropItem, DraggableCollectionEndEvent, DraggableCollection export type {Key, Selection, SortDescriptor, SortDirection, SelectionMode} from 'react-stately'; export type {ValidationResult, RouterConfig} from '@react-types/shared'; export type {Color, ColorSpace, ColorFormat} from '@react-types/color'; -export type {ListLayoutOptions, TableLayoutOptions} from '@react-stately/layout'; +export type {ListLayoutOptions} from '@react-stately/layout'; diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 04f283fdcea..d944ece0c0a 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -837,14 +837,14 @@ describe('Table', () => { rows = getAllByRole('row'); expect(rows).toHaveLength(8); - expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 7Bar 7', 'Foo 8Bar 8', 'Foo 9Bar 9', 'Foo 10Bar 10', 'Foo 11Bar 11', 'Foo 12Bar 12', 'Foo 13Bar 13']); + expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 8Bar 8', 'Foo 9Bar 9', 'Foo 10Bar 10', 'Foo 11Bar 11', 'Foo 12Bar 12', 'Foo 13Bar 13', 'Foo 14Bar 14']); await user.tab(); await user.keyboard('{End}'); rows = getAllByRole('row'); expect(rows).toHaveLength(9); - expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 7Bar 7', 'Foo 8Bar 8', 'Foo 9Bar 9', 'Foo 10Bar 10', 'Foo 11Bar 11', 'Foo 12Bar 12', 'Foo 13Bar 13', 'Foo 49Bar 49']); + expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 8Bar 8', 'Foo 9Bar 9', 'Foo 10Bar 10', 'Foo 11Bar 11', 'Foo 12Bar 12', 'Foo 13Bar 13', 'Foo 14Bar 14', 'Foo 49Bar 49']); }); describe('drag and drop', () => {