From 94e19e89747628f74549276e476fcbae902cb0d1 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Jun 2024 18:21:24 -0700 Subject: [PATCH 01/14] Add LayoutDelegate interface This enables us to share a common KeyboardDelegate implementation between virtualized and non-virtualized collections --- .../autocomplete/src/useSearchAutocomplete.ts | 12 +- .../@react-aria/combobox/src/useComboBox.ts | 25 +++- .../dnd/stories/VirtualizedListBox.tsx | 18 +-- packages/@react-aria/grid/package.json | 1 - .../grid/src/GridKeyboardDelegate.ts | 63 ++------ .../@react-aria/gridlist/src/useGridList.ts | 11 +- .../@react-aria/listbox/src/useListBox.ts | 9 +- .../selection/src/DOMLayoutDelegate.ts | 58 ++++++++ .../selection/src/ListKeyboardDelegate.ts | 77 +++++----- packages/@react-aria/selection/src/index.ts | 1 + .../selection/src/useSelectableList.ts | 16 ++- packages/@react-aria/table/package.json | 1 - packages/@react-aria/table/src/useTable.ts | 15 +- .../src/MobileSearchAutocomplete.tsx | 4 +- .../autocomplete/src/SearchAutocomplete.tsx | 4 +- .../@react-spectrum/combobox/src/ComboBox.tsx | 4 +- .../combobox/src/MobileComboBox.tsx | 4 +- packages/@react-spectrum/list/package.json | 1 + .../@react-spectrum/list/src/ListView.tsx | 24 ++-- .../list/test/ListView.test.js | 2 + .../@react-spectrum/listbox/src/ListBox.tsx | 2 +- .../listbox/src/ListBoxBase.tsx | 13 +- .../@react-spectrum/picker/src/Picker.tsx | 5 +- packages/@react-spectrum/table/package.json | 1 + .../table/src/TableViewBase.tsx | 9 +- .../@react-stately/layout/src/ListLayout.ts | 135 ++---------------- .../@react-stately/virtualizer/src/Layout.ts | 12 +- .../@react-types/shared/src/collections.d.ts | 22 +++ 28 files changed, 262 insertions(+), 287 deletions(-) create mode 100644 packages/@react-aria/selection/src/DOMLayoutDelegate.ts diff --git a/packages/@react-aria/autocomplete/src/useSearchAutocomplete.ts b/packages/@react-aria/autocomplete/src/useSearchAutocomplete.ts index a7aa5869053..f820e779abd 100644 --- a/packages/@react-aria/autocomplete/src/useSearchAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useSearchAutocomplete.ts @@ -14,7 +14,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; import {AriaSearchAutocompleteProps} from '@react-types/autocomplete'; import {ComboBoxState} from '@react-stately/combobox'; -import {DOMAttributes, KeyboardDelegate, ValidationResult} from '@react-types/shared'; +import {DOMAttributes, KeyboardDelegate, LayoutDelegate, ValidationResult} from '@react-types/shared'; import {InputHTMLAttributes, RefObject} from 'react'; import {mergeProps} from '@react-aria/utils'; import {useComboBox} from '@react-aria/combobox'; @@ -43,7 +43,13 @@ export interface AriaSearchAutocompleteOptions extends AriaSearchAutocomplete /** The ref for the list box. */ listBoxRef: RefObject, /** An optional keyboard delegate implementation, to override the default. */ - keyboardDelegate?: KeyboardDelegate + keyboardDelegate?: KeyboardDelegate, + /** + * A delegate object that provides layout information for items in the collection. + * By default this uses the DOM, but this can be overridden to implement things like + * virtualized scrolling. + */ + layoutDelegate?: LayoutDelegate } /** @@ -58,6 +64,7 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions inputRef, listBoxRef, keyboardDelegate, + layoutDelegate, onSubmit = () => {}, onClear, onKeyDown, @@ -98,6 +105,7 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions { ...otherProps, keyboardDelegate, + layoutDelegate, popoverRef, listBoxRef, inputRef, diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 5e69fc56f66..39f8bfd5d3e 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; -import {BaseEvent, DOMAttributes, KeyboardDelegate, PressEvent, RouterOptions, ValidationResult} from '@react-types/shared'; +import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RouterOptions, ValidationResult} from '@react-types/shared'; import {chain, isAppleDevice, mergeProps, useLabels, useRouter} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, RefObject, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -38,7 +38,13 @@ export interface AriaComboBoxOptions extends Omit, 'chil /** The ref for the optional list box popup trigger button. */ buttonRef?: RefObject, /** An optional keyboard delegate implementation, to override the default. */ - keyboardDelegate?: KeyboardDelegate + keyboardDelegate?: KeyboardDelegate, + /** + * A delegate object that provides layout information for items in the collection. + * By default this uses the DOM, but this can be overridden to implement things like + * virtualized scrolling. + */ + layoutDelegate?: LayoutDelegate } export interface ComboBoxAria extends ValidationResult { @@ -69,6 +75,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta inputRef, listBoxRef, keyboardDelegate, + layoutDelegate, // completionMode = 'suggest', shouldFocusWrap, isReadOnly, @@ -90,10 +97,16 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). // When virtualized, the layout object will be passed in as a prop and override this. - let delegate = useMemo(() => - keyboardDelegate || - new ListKeyboardDelegate(state.collection, state.disabledKeys, listBoxRef) - , [keyboardDelegate, state.collection, state.disabledKeys, listBoxRef]); + let {collection} = state; + let {disabledKeys} = state.selectionManager; + let delegate = useMemo(() => ( + keyboardDelegate || new ListKeyboardDelegate({ + collection, + disabledKeys, + ref: listBoxRef, + layoutDelegate + }) + ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, listBoxRef]); // Use useSelectableCollection to get the keyboard handlers to apply to the textfield let {collectionProps} = useSelectableCollection({ diff --git a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx index fc8ee8e78a2..bbbb5fcb329 100644 --- a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx +++ b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx @@ -19,9 +19,9 @@ import {DroppableCollectionDropEvent} from '@react-types/shared'; import {FocusRing} from '@react-aria/focus'; import Folder from '@spectrum-icons/workflow/Folder'; import {Item} from '@react-stately/collections'; +import {ListKeyboardDelegate} from '@react-aria/selection'; import {ListLayout} from '@react-stately/layout'; import React from 'react'; -import {useCollator} from '@react-aria/i18n'; import {useDropIndicator, useDroppableCollection, useDroppableItem} from '..'; import {useDroppableCollectionState} from '@react-stately/dnd'; import {useListBox, useOption} from '@react-aria/listbox'; @@ -135,21 +135,21 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) { onDrop }); - let collator = useCollator({usage: 'search', sensitivity: 'base'}); let layout = React.useMemo(() => new ListLayout({ estimatedRowHeight: 32, padding: 8, loaderHeight: 40, - placeholderHeight: 32, - collator + placeholderHeight: 32 }) - , [collator]); - - layout.collection = state.collection; + , []); let {collectionProps} = useDroppableCollection({ - keyboardDelegate: layout, + keyboardDelegate: new ListKeyboardDelegate({ + collection: state.collection, + ref: domRef, + layoutDelegate: layout + }), dropTargetDelegate: layout, onDropActivate: chain(action('onDropActivate'), console.log), onDrop @@ -158,7 +158,7 @@ export const VirtualizedListBox = React.forwardRef(function (props: any, ref) { let {listBoxProps} = useListBox({ ...props, 'aria-label': 'List', - keyboardDelegate: layout, + layoutDelegate: layout, isVirtualized: true }, state, domRef); let isDropTarget = dropState.isDropTarget({type: 'root'}); diff --git a/packages/@react-aria/grid/package.json b/packages/@react-aria/grid/package.json index c5fb6975704..a6f6bce8d48 100644 --- a/packages/@react-aria/grid/package.json +++ b/packages/@react-aria/grid/package.json @@ -31,7 +31,6 @@ "@react-stately/collections": "^3.10.7", "@react-stately/grid": "^3.8.7", "@react-stately/selection": "^3.15.1", - "@react-stately/virtualizer": "^3.7.1", "@react-types/checkbox": "^3.8.1", "@react-types/grid": "^3.2.6", "@react-types/shared": "^3.23.1", diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index 880632ef13c..198158c110e 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -10,20 +10,20 @@ * governing permissions and limitations under the License. */ -import {Direction, DisabledBehavior, Key, KeyboardDelegate, Node} from '@react-types/shared'; +import {Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node} 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'; -import {Layout, Rect} from '@react-stately/virtualizer'; import {RefObject} from 'react'; -export interface GridKeyboardDelegateOptions { +export interface GridKeyboardDelegateOptions { collection: C, disabledKeys: Set, disabledBehavior?: DisabledBehavior, ref?: RefObject, direction: Direction, collator?: Intl.Collator, - layout?: Layout>, + layoutDelegate?: LayoutDelegate, focusMode?: 'row' | 'cell' } @@ -31,20 +31,18 @@ export class GridKeyboardDelegate> implements Key collection: C; protected disabledKeys: Set; protected disabledBehavior: DisabledBehavior; - protected ref: RefObject; protected direction: Direction; protected collator: Intl.Collator; - protected layout: Layout>; + protected layoutDelegate: LayoutDelegate; protected focusMode; - constructor(options: GridKeyboardDelegateOptions) { + constructor(options: GridKeyboardDelegateOptions) { this.collection = options.collection; this.disabledKeys = options.disabledKeys; this.disabledBehavior = options.disabledBehavior || 'all'; - this.ref = options.ref; this.direction = options.direction; this.collator = options.collator; - this.layout = options.layout; + this.layoutDelegate = options.layoutDelegate || new DOMLayoutDelegate(options.ref); this.focusMode = options.focusMode || 'row'; } @@ -276,66 +274,35 @@ export class GridKeyboardDelegate> implements Key return key; } - private getItem(key: Key): HTMLElement { - return this.ref.current.querySelector(`[data-key="${CSS.escape(key.toString())}"]`); - } - - private getItemRect(key: Key): Rect { - if (this.layout) { - return this.layout.getLayoutInfo(key)?.rect; - } - - let item = this.getItem(key); - if (item) { - return new Rect(item.offsetLeft, item.offsetTop, item.offsetWidth, item.offsetHeight); - } - } - - private getPageHeight(): number { - if (this.layout) { - return this.layout.virtualizer?.visibleRect.height; - } - - return this.ref?.current?.offsetHeight; - } - - private getContentHeight(): number { - if (this.layout) { - return this.layout.getContentSize().height; - } - - return this.ref?.current?.scrollHeight; - } - getKeyPageAbove(key: Key) { - let itemRect = this.getItemRect(key); + let itemRect = this.layoutDelegate.getItemRect(key); if (!itemRect) { return null; } - let pageY = Math.max(0, itemRect.maxY - this.getPageHeight()); + let pageY = Math.max(0, itemRect.y + itemRect.height - this.layoutDelegate.getVisibleRect().height); while (itemRect && itemRect.y > pageY) { key = this.getKeyAbove(key); - itemRect = this.getItemRect(key); + itemRect = this.layoutDelegate.getItemRect(key); } return key; } getKeyPageBelow(key: Key) { - let itemRect = this.getItemRect(key); + let itemRect = this.layoutDelegate.getItemRect(key); if (!itemRect) { return null; } - let pageHeight = this.getPageHeight(); - let pageY = Math.min(this.getContentHeight(), itemRect.y + pageHeight); + let pageHeight = this.layoutDelegate.getVisibleRect().height; + let pageY = Math.min(this.layoutDelegate.getContentSize().height, itemRect.y + pageHeight); - while (itemRect && itemRect.maxY < pageY) { + while (itemRect && (itemRect.y + itemRect.height) < pageY) { let nextKey = this.getKeyBelow(key); - itemRect = this.getItemRect(nextKey); + itemRect = this.layoutDelegate.getItemRect(nextKey); // Guard against case where maxY of the last key is barely less than pageY due to rounding // and thus it attempts to set key to null diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index eb4b9a7fffa..75753785dc9 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -18,6 +18,7 @@ import { DOMProps, Key, KeyboardDelegate, + LayoutDelegate, MultipleSelection } from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; @@ -55,6 +56,12 @@ export interface AriaGridListOptions extends Omit, 'chil * to override the default. */ keyboardDelegate?: KeyboardDelegate, + /** + * A delegate object that provides layout information for items in the collection. + * By default this uses the DOM, but this can be overridden to implement things like + * virtualized scrolling. + */ + layoutDelegate?: LayoutDelegate, /** * Whether focus should wrap around when the end/start is reached. * @default false @@ -86,6 +93,7 @@ export function useGridList(props: AriaGridListOptions, state: ListState(props: AriaGridListOptions, state: ListState extends Omit, 'childr */ keyboardDelegate?: KeyboardDelegate, + /** + * A delegate object that provides layout information for items in the collection. + * By default this uses the DOM, but this can be overridden to implement things like + * virtualized scrolling. + */ + layoutDelegate?: LayoutDelegate, + /** * Whether the listbox items should use virtual focus instead of being focused directly. */ diff --git a/packages/@react-aria/selection/src/DOMLayoutDelegate.ts b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts new file mode 100644 index 00000000000..3ba211d6d2d --- /dev/null +++ b/packages/@react-aria/selection/src/DOMLayoutDelegate.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Key, LayoutDelegate, Rect, Size} from '@react-types/shared'; +import {RefObject} from 'react'; + +export class DOMLayoutDelegate implements LayoutDelegate { + private ref: RefObject; + + constructor(ref: RefObject) { + this.ref = ref; + } + + getItemRect(key: Key): Rect | null { + let container = this.ref.current; + let item = key != null ? container.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null; + if (!item) { + return null; + } + + let containerRect = container.getBoundingClientRect(); + let itemRect = item.getBoundingClientRect(); + + return { + x: itemRect.left - containerRect.left + container.scrollLeft, + y: itemRect.top - containerRect.top + container.scrollTop, + width: itemRect.width, + height: itemRect.height + }; + } + + getContentSize(): Size { + let container = this.ref.current; + return { + width: container.scrollWidth, + height: container.scrollHeight + }; + } + + getVisibleRect(): Rect { + let container = this.ref.current; + return { + x: container.scrollLeft, + y: container.scrollTop, + width: container.offsetWidth, + height: container.offsetHeight + }; + } +} diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 0552e72acb6..ef24b329f08 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {Collection, Direction, DisabledBehavior, Key, KeyboardDelegate, Node, Orientation} from '@react-types/shared'; +import {Collection, Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Orientation, Rect} from '@react-types/shared'; +import {DOMLayoutDelegate} from './DOMLayoutDelegate'; import {isScrollable} from '@react-aria/utils'; import {RefObject} from 'react'; @@ -22,7 +23,8 @@ interface ListKeyboardDelegateOptions { orientation?: Orientation, direction?: Direction, disabledKeys?: Set, - disabledBehavior?: DisabledBehavior + disabledBehavior?: DisabledBehavior, + layoutDelegate?: LayoutDelegate } export class ListKeyboardDelegate implements KeyboardDelegate { @@ -34,6 +36,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { private layout: 'stack' | 'grid'; private orientation?: Orientation; private direction?: Direction; + private layoutDelegate: LayoutDelegate; constructor(collection: Collection>, disabledKeys: Set, ref: RefObject, collator?: Intl.Collator); constructor(options: ListKeyboardDelegateOptions); @@ -48,6 +51,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.orientation = opts.orientation || 'vertical'; this.direction = opts.direction; this.layout = opts.layout || 'stack'; + this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref); } else { this.collection = args[0]; this.disabledKeys = args[1]; @@ -56,6 +60,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.layout = 'stack'; this.orientation = 'vertical'; this.disabledBehavior = 'all'; + this.layoutDelegate = new DOMLayoutDelegate(this.ref); } // If this is a vertical stack, remove the left/right methods completely @@ -101,29 +106,29 @@ export class ListKeyboardDelegate implements KeyboardDelegate { private findKey( key: Key, nextKey: (key: Key) => Key, - shouldSkip: (prevRect: DOMRect, itemRect: DOMRect) => boolean + shouldSkip: (prevRect: Rect, itemRect: Rect) => boolean ) { - let item = this.getItem(key); - if (!item) { + let itemRect = this.layoutDelegate.getItemRect(key); + if (!itemRect) { return null; } // Find the item above or below in the same column. - let prevRect = item.getBoundingClientRect(); + let prevRect = itemRect; do { key = nextKey(key); - item = this.getItem(key); - } while (item && shouldSkip(prevRect, item.getBoundingClientRect())); + itemRect = this.layoutDelegate.getItemRect(key); + } while (itemRect && shouldSkip(prevRect, itemRect)); return key; } - private isSameRow(prevRect: DOMRect, itemRect: DOMRect) { - return prevRect.top === itemRect.top || prevRect.left !== itemRect.left; + private isSameRow(prevRect: Rect, itemRect: Rect) { + return prevRect.y === itemRect.y || prevRect.x !== itemRect.x; } - private isSameColumn(prevRect: DOMRect, itemRect: DOMRect) { - return prevRect.left === itemRect.left || prevRect.top !== itemRect.top; + private isSameColumn(prevRect: Rect, itemRect: Rect) { + return prevRect.x === itemRect.x || prevRect.y !== itemRect.y; } getKeyBelow(key: Key) { @@ -202,14 +207,10 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return null; } - private getItem(key: Key): HTMLElement { - return key !== null ? this.ref.current.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null; - } - getKeyPageAbove(key: Key) { let menu = this.ref.current; - let item = this.getItem(key); - if (!item) { + let itemRect = this.layoutDelegate.getItemRect(key); + if (!itemRect) { return null; } @@ -217,25 +218,19 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return this.getFirstKey(); } - let containerRect = menu.getBoundingClientRect(); - let itemRect = item.getBoundingClientRect(); if (this.orientation === 'horizontal') { - let containerX = containerRect.x - menu.scrollLeft; - let pageX = Math.max(0, (itemRect.x - containerX) + itemRect.width - containerRect.width); + let pageX = Math.max(0, itemRect.x + itemRect.width - this.layoutDelegate.getVisibleRect().width); - while (item && (itemRect.x - containerX) > pageX) { + while (itemRect && itemRect.x > pageX) { key = this.getKeyAbove(key); - item = key == null ? null : this.getItem(key); - itemRect = item?.getBoundingClientRect(); + itemRect = key == null ? null : this.layoutDelegate.getItemRect(key); } } else { - let containerY = containerRect.y - menu.scrollTop; - let pageY = Math.max(0, (itemRect.y - containerY) + itemRect.height - containerRect.height); + let pageY = Math.max(0, itemRect.y + itemRect.height - this.layoutDelegate.getVisibleRect().height); - while (item && (itemRect.y - containerY) > pageY) { + while (itemRect && itemRect.y > pageY) { key = this.getKeyAbove(key); - item = key == null ? null : this.getItem(key); - itemRect = item?.getBoundingClientRect(); + itemRect = key == null ? null : this.layoutDelegate.getItemRect(key); } } @@ -244,8 +239,8 @@ export class ListKeyboardDelegate implements KeyboardDelegate { getKeyPageBelow(key: Key) { let menu = this.ref.current; - let item = this.getItem(key); - if (!item) { + let itemRect = this.layoutDelegate.getItemRect(key); + if (!itemRect) { return null; } @@ -253,25 +248,19 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return this.getLastKey(); } - let containerRect = menu.getBoundingClientRect(); - let itemRect = item.getBoundingClientRect(); if (this.orientation === 'horizontal') { - let containerX = containerRect.x - menu.scrollLeft; - let pageX = Math.min(menu.scrollWidth, (itemRect.x - containerX) - itemRect.width + containerRect.width); + let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width); - while (item && (itemRect.x - containerX) < pageX) { + while (itemRect && itemRect.x < pageX) { key = this.getKeyBelow(key); - item = key == null ? null : this.getItem(key); - itemRect = item?.getBoundingClientRect(); + itemRect = key == null ? null : this.layoutDelegate.getItemRect(key); } } else { - let containerY = containerRect.y - menu.scrollTop; - let pageY = Math.min(menu.scrollHeight, (itemRect.y - containerY) - itemRect.height + containerRect.height); + let pageY = Math.min(this.layoutDelegate.getContentSize().height, itemRect.y - itemRect.height + this.layoutDelegate.getVisibleRect().height); - while (item && (itemRect.y - containerY) < pageY) { + while (itemRect && itemRect.y < pageY) { key = this.getKeyBelow(key); - item = key == null ? null : this.getItem(key); - itemRect = item?.getBoundingClientRect(); + itemRect = key == null ? null : this.layoutDelegate.getItemRect(key); } } diff --git a/packages/@react-aria/selection/src/index.ts b/packages/@react-aria/selection/src/index.ts index e17637eafc9..189275687bd 100644 --- a/packages/@react-aria/selection/src/index.ts +++ b/packages/@react-aria/selection/src/index.ts @@ -14,6 +14,7 @@ export {useSelectableCollection} from './useSelectableCollection'; export {useSelectableItem} from './useSelectableItem'; export {useSelectableList} from './useSelectableList'; export {ListKeyboardDelegate} from './ListKeyboardDelegate'; +export {DOMLayoutDelegate} from './DOMLayoutDelegate'; export {useTypeSelect} from './useTypeSelect'; export type {AriaSelectableCollectionOptions, SelectableCollectionAria} from './useSelectableCollection'; diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index 803c74cd9f7..98072b7c3ee 100644 --- a/packages/@react-aria/selection/src/useSelectableList.ts +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -11,7 +11,7 @@ */ import {AriaSelectableCollectionOptions, useSelectableCollection} from './useSelectableCollection'; -import {Collection, DOMAttributes, Key, KeyboardDelegate, Node} from '@react-types/shared'; +import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared'; import {ListKeyboardDelegate} from './ListKeyboardDelegate'; import {useCollator} from '@react-aria/i18n'; import {useMemo} from 'react'; @@ -25,6 +25,12 @@ export interface AriaSelectableListOptions extends Omit extends GridProps { +export interface AriaTableProps extends GridProps { /** The layout object for the table. Computes what content is visible and how to position and style them. */ - layout?: Layout> + layoutDelegate?: LayoutDelegate } /** @@ -37,11 +36,11 @@ export interface AriaTableProps extends GridProps { * @param state - State for the table, as returned by `useTableState`. * @param ref - The ref attached to the table element. */ -export function useTable(props: AriaTableProps, state: TableState | TreeGridState, ref: RefObject): GridAria { +export function useTable(props: AriaTableProps, state: TableState | TreeGridState, ref: RefObject): GridAria { let { keyboardDelegate, isVirtualized, - layout + layoutDelegate } = props; // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). @@ -56,8 +55,8 @@ export function useTable(props: AriaTableProps, state: TableState | Tre ref, direction, collator, - layout - }), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layout]); + layoutDelegate + }), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate]); let id = useId(props.id); gridIds.set(state, id); diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index d421719dda9..e6bf6384dbb 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -394,13 +394,13 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { let popoverRef = useRef(null); let listBoxRef = useRef(null); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/autocomplete'); let {inputProps, listBoxProps, labelProps, clearButtonProps} = useSearchAutocomplete( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, popoverRef: popoverRef, listBoxRef, inputRef, diff --git a/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx index fa87f178c11..459814b0fff 100644 --- a/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/SearchAutocomplete.tsx @@ -100,12 +100,12 @@ function _SearchAutocompleteBase(props: SpectrumSearchAutocomp validate: useCallback(v => validate?.(v.inputValue), [validate]) } ); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let {inputProps, listBoxProps, labelProps, clearButtonProps, descriptionProps, errorMessageProps, isInvalid, validationErrors, validationDetails} = useSearchAutocomplete( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, popoverRef: unwrappedPopoverRef, listBoxRef, inputRef, diff --git a/packages/@react-spectrum/combobox/src/ComboBox.tsx b/packages/@react-spectrum/combobox/src/ComboBox.tsx index b610a302c36..090115ee370 100644 --- a/packages/@react-spectrum/combobox/src/ComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/ComboBox.tsx @@ -107,12 +107,12 @@ const ComboBoxBase = React.forwardRef(function ComboBoxBase(pr allowsEmptyCollection: isAsync } ); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let {buttonProps, inputProps, listBoxProps, labelProps, descriptionProps, errorMessageProps, isInvalid, validationErrors, validationDetails} = useComboBox( { ...props, - keyboardDelegate: layout, + layoutDelegate: layout, buttonRef: unwrappedButtonRef, popoverRef: unwrappedPopoverRef, listBoxRef, diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 1d32e3600fc..7fdad889ede 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -334,14 +334,14 @@ function ComboBoxTray(props: ComboBoxTrayProps) { let popoverRef = useRef(); let listBoxRef = useRef(); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/combobox'); let {inputProps, listBoxProps, labelProps} = useComboBox( { ...props, // completionMode, - keyboardDelegate: layout, + layoutDelegate: layout, buttonRef: unwrapDOMRef(buttonRef), popoverRef: popoverRef, listBoxRef, diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index 68416e231fc..68d6a9fe270 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -41,6 +41,7 @@ "@react-aria/gridlist": "^3.8.1", "@react-aria/i18n": "^3.11.1", "@react-aria/interactions": "^3.21.3", + "@react-aria/selection": "^3.18.1", "@react-aria/utils": "^3.24.1", "@react-aria/virtualizer": "^3.10.1", "@react-aria/visually-hidden": "^3.8.12", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index c50db28472e..c8a08716690 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -21,6 +21,7 @@ import {FocusRing, FocusScope} from '@react-aria/focus'; 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'; @@ -29,7 +30,7 @@ import {ProgressCircle} from '@react-spectrum/progress'; import React, {JSX, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import RootDropIndicator from './RootDropIndicator'; import {DragPreview as SpectrumDragPreview} from './DragPreview'; -import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer} from '@react-aria/virtualizer'; @@ -93,20 +94,17 @@ const ROW_HEIGHTS = { function useListLayout(state: ListState, density: SpectrumListViewProps['density'], overflowMode: SpectrumListViewProps['overflowMode']) { let {scale} = useProvider(); - let collator = useCollator({usage: 'search', sensitivity: 'base'}); let isEmpty = state.collection.size === 0; let layout = useMemo(() => new ListLayout({ estimatedRowHeight: ROW_HEIGHTS[density][scale], padding: 0, - collator, - loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale] + loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale], + enableEmptyState: true }) // eslint-disable-next-line react-hooks/exhaustive-deps - , [collator, scale, density, isEmpty, overflowMode]); + , [scale, density, isEmpty, overflowMode]); - layout.collection = state.collection; - layout.disabledKeys = state.disabledKeys; return layout; } @@ -159,9 +157,6 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef props.density || 'regular', overflowMode ); - // !!0 is false, so we can cast size or undefined and they'll be falsy - layout.allowDisabledKeyFocus = state.selectionManager.disabledBehavior === 'selection' || !!dragState?.draggingKeys.size; - let DragPreview = dragAndDropHooks?.DragPreview; let dropState: DroppableCollectionState; @@ -173,7 +168,12 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef selectionManager }); droppableCollection = dragAndDropHooks.useDroppableCollection({ - keyboardDelegate: layout, + keyboardDelegate: new ListKeyboardDelegate({ + collection, + disabledKeys: dragState?.draggingKeys.size ? null : selectionManager.disabledKeys, + ref: domRef, + layoutDelegate: layout + }), dropTargetDelegate: layout }, dropState, domRef); @@ -183,7 +183,7 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef let {gridProps} = useGridList({ ...props, isVirtualized: true, - keyboardDelegate: layout, + layoutDelegate: layout, onAction }, state, domRef); diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 6f4eab54f16..5670db4d127 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -490,6 +490,7 @@ describe('ListView', function () { describe('PageDown', function () { it('should move focus to a row a page below when focus starts on a row', async function () { let tree = renderListWithFocusables({items: manyItems, selectionMode: 'single'}); + tree.getByRole('grid').style.overflow = 'auto'; // make ListKeyboardDelegate know we are scrollable await user.tab(); moveFocus('PageDown'); expect(document.activeElement).toBe(getRow(tree, 'Foo 25')); @@ -499,6 +500,7 @@ describe('ListView', function () { it('should move focus to a row a page below when focus starts in the row cell', async function () { let tree = renderListWithFocusables({items: manyItems}); + tree.getByRole('grid').style.overflow = 'auto'; // make ListKeyboardDelegate know we are scrollable let focusables = within(getRow(tree, 'Foo 1')).getAllByRole('button'); let start = focusables[0]; await user.click(start); diff --git a/packages/@react-spectrum/listbox/src/ListBox.tsx b/packages/@react-spectrum/listbox/src/ListBox.tsx index 650d7133baa..9d614af8c20 100644 --- a/packages/@react-spectrum/listbox/src/ListBox.tsx +++ b/packages/@react-spectrum/listbox/src/ListBox.tsx @@ -19,7 +19,7 @@ import {useListState} from '@react-stately/list'; function ListBox(props: SpectrumListBoxProps, ref: DOMRef) { let state = useListState(props); - let layout = useListBoxLayout(state); + let layout = useListBoxLayout(); let domRef = useDOMRef(ref); return ( diff --git a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx index 977af2827eb..a57970c511b 100644 --- a/packages/@react-spectrum/listbox/src/ListBoxBase.tsx +++ b/packages/@react-spectrum/listbox/src/ListBoxBase.tsx @@ -26,7 +26,7 @@ import {ProgressCircle} from '@react-spectrum/progress'; import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo} from 'react'; import {ReusableView} from '@react-stately/virtualizer'; import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; -import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer, VirtualizerItem} from '@react-aria/virtualizer'; @@ -48,9 +48,8 @@ interface ListBoxBaseProps extends AriaListBoxOptions, DOMProps, AriaLabel } /** @private */ -export function useListBoxLayout(state: ListState): ListLayout { +export function useListBoxLayout(): ListLayout { let {scale} = useProvider(); - let collator = useCollator({usage: 'search', sensitivity: 'base'}); let layout = useMemo(() => new ListLayout({ estimatedRowHeight: scale === 'large' ? 48 : 32, @@ -58,12 +57,10 @@ export function useListBoxLayout(state: ListState): ListLayout { padding: scale === 'large' ? 5 : 4, // TODO: get from DNA loaderHeight: 40, placeholderHeight: scale === 'large' ? 48 : 32, - collator + enableEmptyState: true }) - , [collator, scale]); + , [scale]); - layout.collection = state.collection; - layout.disabledKeys = state.disabledKeys; return layout; } @@ -72,7 +69,7 @@ function ListBoxBase(props: ListBoxBaseProps, ref: RefObject(props: SpectrumPickerProps, ref: DOMRef(props: TableBaseProps, ref: DOMRef(props: TableBaseProps, ref: DOMRef = { estimatedHeadingHeight?: number, padding?: number, indentationForItem?: (collection: Collection>, key: Key) => number, - collator?: Intl.Collator, loaderHeight?: number, placeholderHeight?: number, - allowDisabledKeyFocus?: boolean }; // A wrapper around LayoutInfo that supports hierarchy @@ -45,16 +43,16 @@ interface ListLayoutProps { const DEFAULT_HEIGHT = 48; /** - * The ListLayout class is an implementation of a collection view {@link Layout} + * The ListLayout class is an implementation of a virtualizer {@link Layout} * it is used for creating lists and lists with indented sub-lists. * * 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 collection view + * The {@link ListKeyboardDelegate} extends the existing virtualizer * delegate with an additional method to do this (it uses the same delegate object as - * the collection view itself). + * the virtualizer itself). */ -export class ListLayout extends Layout, ListLayoutProps> implements KeyboardDelegate, DropTargetDelegate { +export class ListLayout extends Layout, ListLayoutProps> implements DropTargetDelegate { protected rowHeight: number; protected estimatedRowHeight: number; protected headingHeight: number; @@ -64,14 +62,11 @@ export class ListLayout extends Layout, ListLayoutProps> implements K protected layoutInfos: Map; protected layoutNodes: Map; protected contentSize: Size; - collection: Collection>; - disabledKeys: Set = new Set(); - allowDisabledKeyFocus: boolean = false; - isLoading: boolean; + protected collection: Collection>; + protected isLoading: boolean; protected lastWidth: number; protected lastCollection: Collection>; protected rootNodes: LayoutNode[]; - protected collator: Intl.Collator; protected invalidateEverything: boolean; protected loaderHeight: number; protected placeholderHeight: number; @@ -90,7 +85,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements K this.estimatedHeadingHeight = options.estimatedHeadingHeight; this.padding = options.padding || 0; this.indentationForItem = options.indentationForItem; - this.collator = options.collator; this.loaderHeight = options.loaderHeight; this.placeholderHeight = options.placeholderHeight; this.layoutInfos = new Map(); @@ -98,7 +92,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements K this.rootNodes = []; this.lastWidth = 0; this.lastCollection = null; - this.allowDisabledKeyFocus = options.allowDisabledKeyFocus; this.lastValidRect = new Rect(); this.validRect = new Rect(); this.contentSize = new Size(); @@ -363,7 +356,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements K // If no explicit height is available, use an estimated height. if (rectHeight == null) { // If a previous version of this layout info exists, reuse its height. - // Mark as estimated if the size of the overall collection view changed, + // 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; @@ -400,7 +393,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements K // If no explicit height is available, use an estimated height. if (rectHeight == null) { // If a previous version of this layout info exists, reuse its height. - // Mark as estimated if the size of the overall collection view changed, + // 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); if (previousLayoutNode) { @@ -479,116 +472,6 @@ export class ListLayout extends Layout, ListLayoutProps> implements K return this.contentSize; } - getKeyAbove(key: Key): Key | null { - let collection = this.collection; - - key = collection.getKeyBefore(key); - while (key != null) { - let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { - return key; - } - - key = collection.getKeyBefore(key); - } - } - - getKeyBelow(key: Key): Key | null { - let collection = this.collection; - - key = collection.getKeyAfter(key); - while (key != null) { - let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { - return key; - } - - key = collection.getKeyAfter(key); - } - } - - getKeyPageAbove(key: Key): Key | null { - let layoutInfo = this.getLayoutInfo(key); - - if (layoutInfo) { - let pageY = Math.max(0, layoutInfo.rect.y + layoutInfo.rect.height - this.virtualizer.visibleRect.height); - while (layoutInfo && layoutInfo.rect.y > pageY) { - let keyAbove = this.getKeyAbove(layoutInfo.key); - layoutInfo = this.getLayoutInfo(keyAbove); - } - - if (layoutInfo) { - return layoutInfo.key; - } - } - - return this.getFirstKey(); - } - - getKeyPageBelow(key: Key): Key | null { - let layoutInfo = this.getLayoutInfo(key != null ? key : this.getFirstKey()); - - if (layoutInfo) { - let pageY = Math.min(this.virtualizer.contentSize.height, layoutInfo.rect.y - layoutInfo.rect.height + this.virtualizer.visibleRect.height); - while (layoutInfo && layoutInfo.rect.y < pageY) { - let keyBelow = this.getKeyBelow(layoutInfo.key); - layoutInfo = this.getLayoutInfo(keyBelow); - } - - if (layoutInfo) { - return layoutInfo.key; - } - } - - return this.getLastKey(); - } - - getFirstKey(): Key | null { - let collection = this.collection; - let key = collection.getFirstKey(); - while (key != null) { - let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { - return key; - } - - key = collection.getKeyAfter(key); - } - } - - getLastKey(): Key | null { - let collection = this.collection; - let key = collection.getLastKey(); - while (key != null) { - let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { - return key; - } - - key = collection.getKeyBefore(key); - } - } - - getKeyForSearch(search: string, fromKey?: Key): Key | null { - if (!this.collator) { - return null; - } - - let collection = this.collection; - let key = fromKey || this.getFirstKey(); - while (key != null) { - let item = collection.getItem(key); - let substring = item.textValue.slice(0, search.length); - if (item.textValue && this.collator.compare(substring, search) === 0) { - return key; - } - - key = this.getKeyBelow(key); - } - - return null; - } - 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/virtualizer/src/Layout.ts b/packages/@react-stately/virtualizer/src/Layout.ts index 739a01d7f35..267e6625b23 100644 --- a/packages/@react-stately/virtualizer/src/Layout.ts +++ b/packages/@react-stately/virtualizer/src/Layout.ts @@ -11,7 +11,7 @@ */ import {InvalidationContext} from './types'; -import {Key} from '@react-types/shared'; +import {Key, LayoutDelegate} from '@react-types/shared'; import {LayoutInfo} from './LayoutInfo'; import {Rect} from './Rect'; import {Size} from './Size'; @@ -30,7 +30,7 @@ import {Virtualizer} from './Virtualizer'; * @see {@link getVisibleLayoutInfos} * @see {@link getLayoutInfo} */ -export abstract class Layout { +export abstract class Layout implements LayoutDelegate { /** The Virtualizer the layout is currently attached to. */ virtualizer: Virtualizer; @@ -77,4 +77,12 @@ export abstract class Layout { * Updates the size of the given item. */ updateItemSize?(key: Key, size: Size): boolean; + + getItemRect(key: Key): Rect { + return this.getLayoutInfo(key)?.rect; + } + + getVisibleRect(): Rect { + return this.virtualizer.visibleRect; + } } diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index b3d723f71cb..c3982621d63 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -123,6 +123,28 @@ export interface KeyboardDelegate { getKeyForSearch?(search: string, fromKey?: Key): Key | null } +export interface Rect { + x: number, + y: number, + width: number, + height: number +} + +export interface Size { + width: number, + height: number +} + +/** A LayoutDelegate provides layout information for collection items. */ +export interface LayoutDelegate { + /** Returns a rectangle for the item with the given key. */ + getItemRect(key: Key): Rect | null, + /** Returns the visible rectangle of the collection. */ + getVisibleRect(): Rect, + /** Returns the size of the scrollable content in the collection. */ + getContentSize(): Size +} + /** * A generic interface to access a readonly sequential * collection of unique keyed items. From 666c7dd98a2e1115b57db0e3b01fd6e779807c65 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Jun 2024 18:26:49 -0700 Subject: [PATCH 02/14] Unify Spectrum and Aria column resizing implementation We now use the hook in Spectrum and pass the resulting columnWidths into the virtualizer layout --- .../@react-spectrum/table/src/Resizer.tsx | 8 +- .../table/src/TableViewBase.tsx | 160 ++++++++++-------- .../@react-stately/layout/src/TableLayout.ts | 103 +++-------- .../table/src/TableColumnLayout.ts | 83 ++------- .../table/src/useTableColumnResizeState.ts | 16 +- .../table/test/TableUtils.test.js | 8 - 6 files changed, 147 insertions(+), 231 deletions(-) diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 90dd36d71e9..7a15872ab01 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -11,9 +11,10 @@ import {GridNode} from '@react-types/grid'; import intlMessages from '../intl/*.json'; import {isWebKit, mergeProps} from '@react-aria/utils'; import {Key} from '@react-types/shared'; -import React, {RefObject, useEffect, useState} from 'react'; +import React, {createContext, RefObject, useContext, useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import styles from '@adobe/spectrum-css-temp/components/table/vars.css'; +import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext, useVirtualizerContext} from './TableViewBase'; @@ -47,9 +48,12 @@ const CURSORS = { e: getCursor(eCursor, 'e-resize') }; +export const ResizeStateContext = createContext | null>(null); + function Resizer(props: ResizerProps, ref: RefObject) { let {column, showResizer} = props; - let {isEmpty, layout, onFocusedResizer} = useTableContext(); + let {isEmpty, onFocusedResizer} = useTableContext(); + let layout = useContext(ResizeStateContext)!; // Virtualizer re-renders, but these components are all cached // in order to get around that and cause a rerender here, we use context // but we don't actually need any value, they are available on the layout object diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 5c1ac39af6b..68ca1d7982d 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -11,7 +11,6 @@ */ import ArrowDownSmall from '@spectrum-icons/ui/ArrowDownSmall'; -import {chain, isAndroid, mergeProps, scrollIntoView, scrollIntoViewport} from '@react-aria/utils'; import {Checkbox} from '@react-spectrum/checkbox'; import ChevronDownMedium from '@spectrum-icons/ui/ChevronDownMedium'; import ChevronLeftMedium from '@spectrum-icons/ui/ChevronLeftMedium'; @@ -23,7 +22,7 @@ import { useStyleProps, useUnwrapDOMRef } from '@react-spectrum/utils'; -import {ColumnSize, SpectrumColumnProps} from '@react-types/table'; +import {ColumnSize, SpectrumColumnProps, TableCollection} from '@react-types/table'; import {DOMRef, DropTarget, FocusableElement, FocusableRef, Key} from '@react-types/shared'; import type {DragAndDropHooks} from '@react-spectrum/dnd'; import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; @@ -34,21 +33,23 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {isAndroid, mergeProps, scrollIntoView, scrollIntoViewport} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; -import {LayoutInfo, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; +import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, useVirtualizer, VirtualizerItem} from '@react-aria/virtualizer'; import ListGripper from '@spectrum-icons/ui/ListGripper'; +import {ListKeyboardDelegate} from '@react-aria/selection'; import {Nubbin} from './Nubbin'; import {ProgressCircle} from '@react-spectrum/progress'; -import React, {DOMAttributes, HTMLAttributes, ReactElement, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {Resizer} from './Resizer'; +import React, {DOMAttributes, HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {Resizer, ResizeStateContext} from './Resizer'; import {RootDropIndicator} from './RootDropIndicator'; 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 {TableColumnLayout, TableState, TreeGridState} from '@react-stately/table'; import {TableLayout} from '@react-stately/layout'; +import {TableState, TreeGridState, useTableColumnResizeState} from '@react-stately/table'; import {Tooltip, TooltipTrigger} from '@react-spectrum/tooltip'; import {useButton} from '@react-aria/button'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -112,7 +113,7 @@ export interface TableContextValue { dragAndDropHooks: DragAndDropHooks['dragAndDropHooks'], isTableDraggable: boolean, isTableDroppable: boolean, - layout: TableLayout & {tableState: TableState | TreeGridState}, + layout: TableLayout, headerRowHovered: boolean, isInResizeMode: boolean, setIsInResizeMode: (val: boolean) => void, @@ -140,6 +141,8 @@ interface TableBaseProps extends SpectrumTableProps { state: TableState | TreeGridState } +type View = ReusableView, ReactNode>; + function TableViewBase(props: TableBaseProps, ref: DOMRef) { props = useProviderProps(props); let { @@ -169,29 +172,6 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef): ColumnSize | null | undefined => { - if (hideHeader) { - let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; - return showDivider ? width + 1 : width; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH[scale]; - } else if (isDragButtonCell) { - return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; - } - }, [scale]); - - const getDefaultMinWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { - if (hideHeader) { - let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; - return showDivider ? width + 1 : width; - } else if (isSelectionCell) { - return SELECTION_CELL_DEFAULT_WIDTH[scale]; - } else if (isDragButtonCell) { - return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; - } - return 75; - }, [scale]); - // Starts when the user selects resize from the menu, ends when resizing ends // used to control the visibility of the resizer Nubbin let [isInResizeMode, setIsInResizeMode] = useState(false); @@ -205,14 +185,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(); let density = props.density || 'regular'; - let columnLayout = useMemo( - () => new TableColumnLayout({ - getDefaultWidth, - getDefaultMinWidth - }), - [getDefaultWidth, getDefaultMinWidth] - ); - let tableLayout = useMemo(() => new TableLayout({ + let layout = useMemo(() => new TableLayout({ // If props.rowHeight is auto, then use estimated heights based on scale, otherwise use fixed heights. rowHeight: props.overflowMode === 'wrap' ? null @@ -226,25 +199,13 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef { - let proxy = new Proxy(tableLayout, { - get(target, prop, receiver) { - return prop === 'tableState' ? state : Reflect.get(target, prop, receiver); - } - }); - return proxy as TableLayout & {tableState: TableState | TreeGridState}; - }, [state, tableLayout]); - let dragState: DraggableCollectionState; let preview = useRef(null); if (isTableDraggable) { @@ -289,7 +250,6 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef, ReactNode>; let renderWrapper = useCallback((parent: View, reusableView: View, children: View[], renderChildren: (views: View[]) => ReactElement[]) => { if (reusableView.viewType === 'rowgroup') { return ( @@ -500,9 +460,8 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(props: TableBaseProps, ref: DOMRef extends HTMLAttributes { + tableState: TableState, + layout: TableLayout, + collection: TableCollection, + focusedKey: Key | null, + renderView: (type: string, content: GridNode) => ReactElement, + renderWrapper?: ( + parent: View | null, + reusableView: View, + children: View[], + renderChildren: (views: View[]) => ReactElement[] + ) => ReactElement, + domRef: RefObject, + bodyRef: RefObject, + headerRef: RefObject, + onVisibleRectChange: (rect: Rect) => void, + isFocusVisible: boolean, + isVirtualDragging: boolean, + isRootDropTarget: boolean +} + // This is a custom Virtualizer that also has a header that syncs its scroll position with the body. -function TableVirtualizer(props) { - let {layout, collection, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; +function TableVirtualizer(props: TableVirtualizerProps) { + let {tableState, layout, collection, focusedKey, renderView, renderWrapper, domRef, bodyRef, headerRef, onVisibleRectChange: onVisibleRectChangeProp, isFocusVisible, isVirtualDragging, isRootDropTarget, ...otherProps} = props; let {direction} = useLocale(); let loadingState = collection.body.props.loadingState; let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let onLoadMore = collection.body.props.onLoadMore; + let [tableWidth, setTableWidth] = useState(0); + let {scale} = useProvider(); + + const getDefaultWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; + } + }, [scale]); + + const getDefaultMinWidth = useCallback(({props: {hideHeader, isSelectionCell, showDivider, isDragButtonCell}}: GridNode): ColumnSize | null | undefined => { + if (hideHeader) { + let width = DEFAULT_HIDE_HEADER_CELL_WIDTH[scale]; + return showDivider ? width + 1 : width; + } else if (isSelectionCell) { + return SELECTION_CELL_DEFAULT_WIDTH[scale]; + } else if (isDragButtonCell) { + return DRAG_BUTTON_CELL_DEFAULT_WIDTH[scale]; + } + return 75; + }, [scale]); + + let columnResizeState = useTableColumnResizeState({ + tableWidth, + getDefaultWidth, + getDefaultMinWidth + }, tableState); let state = useVirtualizerState({ layout, @@ -549,7 +560,10 @@ function TableVirtualizer(props) { bodyRef.current.scrollTop = rect.y; setScrollLeft(bodyRef.current, direction, rect.x); }, - persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]) + persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]), + layoutOptions: useMemo(() => ({ + columnWidths: columnResizeState.columnWidths + }), [columnResizeState.columnWidths]) }); let memoedVirtualizerProps = useMemo(() => ({ @@ -560,7 +574,11 @@ function TableVirtualizer(props) { }), [otherProps.tabIndex, focusedKey, isLoading, onLoadMore]); let {virtualizerProps, scrollViewProps: {onVisibleRectChange}} = useVirtualizer(memoedVirtualizerProps, state, domRef); - let onVisibleRectChangeMemo = useMemo(() => chain(onVisibleRectChange, onVisibleRectChangeProp), [onVisibleRectChange, onVisibleRectChangeProp]); + let onVisibleRectChangeMemo = useCallback(rect => { + setTableWidth(rect.width); + onVisibleRectChange(rect); + onVisibleRectChangeProp(rect); + }, [onVisibleRectChange, onVisibleRectChangeProp]); // this effect runs whenever the contentSize changes, it doesn't matter what the content size is // only that it changes in a resize, and when that happens, we want to sync the body to the @@ -580,7 +598,7 @@ function TableVirtualizer(props) { headerRef.current.scrollLeft = bodyRef.current.scrollLeft; }, [bodyRef, headerRef]); - let resizerPosition = layout.getResizerPosition() - 2; + let resizerPosition = columnResizeState.resizingColumn != null ? layout.getLayoutInfo(columnResizeState.resizingColumn).rect.maxX - 2 : 0; let resizerAtEdge = resizerPosition > Math.max(state.virtualizer.contentSize.width, state.virtualizer.visibleRect.width) - 3; // this should be fine, every movement of the resizer causes a rerender @@ -589,11 +607,11 @@ function TableVirtualizer(props) { let shouldHardCornerResizeCorner = resizerAtEdge && resizerInVisibleRegion; // minimize re-render caused on Resizers by memoing this - let resizingColumnWidth = layout.getColumnWidth(layout.resizingColumn); + let resizingColumnWidth = columnResizeState.resizingColumn != null ? columnResizeState.getColumnWidth(columnResizeState.resizingColumn) : 0; let resizingColumn = useMemo(() => ({ width: resizingColumnWidth, - key: layout.resizingColumn - }), [resizingColumnWidth, layout.resizingColumn]); + key: columnResizeState.resizingColumn + }), [resizingColumnWidth, columnResizeState.resizingColumn]); let mergedProps = mergeProps( otherProps, virtualizerProps, @@ -603,7 +621,7 @@ function TableVirtualizer(props) { let firstColumn = collection.columns[0]; let scrollPadding = 0; if (firstColumn.props.isSelectionCell || firstColumn.props.isDragButtonCell) { - scrollPadding = layout.getColumnWidth(firstColumn.key); + scrollPadding = columnResizeState.getColumnWidth(firstColumn.key); } return ( @@ -623,7 +641,9 @@ function TableVirtualizer(props) { scrollPaddingInlineStart: scrollPadding }} ref={headerRef}> - {state.visibleViews[0]} + + {state.visibleViews[0]} + + style={{[direction === 'ltr' ? 'left' : 'right']: `${resizerPosition}px`, height: `${Math.max(state.virtualizer.contentSize.height, state.virtualizer.visibleRect.height)}px`, display: columnResizeState.resizingColumn ? 'block' : 'none'}} /> @@ -798,7 +818,6 @@ function ResizableTableColumnHeader(props) { let resizingRef = useRef(null); let { state, - layout, onResizeStart, onResize, onResizeEnd, @@ -809,6 +828,7 @@ function ResizableTableColumnHeader(props) { headerMenuOpen, setHeaderMenuOpen } = useTableContext(); + let columnResizeState = useContext(ResizeStateContext); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/table'); let {pressProps, isPressed} = usePress({isDisabled: isEmpty}); let {columnHeaderProps} = useTableColumnHeader({ @@ -833,7 +853,7 @@ function ResizableTableColumnHeader(props) { state.sort(column.key, 'descending'); break; case 'resize': - layout.startResize(column.key); + columnResizeState.startResize(column.key); setIsInResizeMode(true); state.setKeyboardNavigationDisabled(true); break; @@ -859,7 +879,7 @@ function ResizableTableColumnHeader(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [allowsSorting]); - let resizingColumn = layout.resizingColumn; + let resizingColumn = columnResizeState.resizingColumn; let showResizer = !isEmpty && ((headerRowHovered && getInteractionModality() !== 'keyboard') || resizingColumn != null); let alignment = 'start'; let menuAlign = 'start' as 'start' | 'end'; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index e85f7785423..7cb66951ca4 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -23,95 +23,55 @@ type TableLayoutOptions = ListLayoutOptions & { initialCollection: TableCollection } +export interface TableLayoutProps extends ListLayoutProps { + columnWidths?: Map +} + export class TableLayout extends ListLayout { collection: TableCollection; lastCollection: TableCollection; - columnWidths: Map = new Map(); + columnWidths: Map; stickyColumnIndices: number[]; wasLoading = false; isLoading = false; lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); private disableSticky: boolean; - columnLayout: TableColumnLayout; - controlledColumns: Map>; - uncontrolledColumns: Map>; - uncontrolledWidths: Map; - resizingColumn: Key | null; constructor(options: TableLayoutOptions) { super(options); this.collection = options.initialCollection; this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); - this.columnLayout = options.columnLayout; - let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); - this.controlledColumns = controlledColumns; - this.uncontrolledColumns = uncontrolledColumns; - this.uncontrolledWidths = this.columnLayout.getInitialUncontrolledWidths(uncontrolledColumns); } - protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { - // If columns changed, clear layout cache. - return super.shouldInvalidateEverything(invalidationContext) || ( - !this.lastCollection || - this.collection.columns.length !== this.lastCollection.columns.length || - this.collection.columns.some((c, i) => - c.key !== this.lastCollection.columns[i].key || - c.props.width !== this.lastCollection.columns[i].props.width || - c.props.minWidth !== this.lastCollection.columns[i].props.minWidth || - c.props.maxWidth !== this.lastCollection.columns[i].props.maxWidth - ) - ); + private columnsChanged(newCollection: TableCollection, oldCollection: TableCollection | null) { + return !oldCollection || + newCollection.columns !== oldCollection.columns && + newCollection.columns.length !== oldCollection.columns.length || + newCollection.columns.some((c, i) => + c.key !== oldCollection.columns[i].key || + c.props.width !== oldCollection.columns[i].props.width || + c.props.minWidth !== oldCollection.columns[i].props.minWidth || + c.props.maxWidth !== oldCollection.columns[i].props.maxWidth + ); } - getResizerPosition(): Key { - return this.getLayoutInfo(this.resizingColumn)?.rect.maxX; + validate(invalidationContext: InvalidationContext): void { + let newCollection = this.virtualizer.collection as TableCollection; + + // If columnWidths were provided via layoutOptions, update those. + // Otherwise, calculate column widths ourselves. + if (invalidationContext.layoutOptions?.columnWidths && invalidationContext.layoutOptions.columnWidths !== this.columnWidths) { + this.columnWidths = invalidationContext.layoutOptions.columnWidths; + invalidationContext.sizeChanged = true; + } else if (invalidationContext.sizeChanged || this.columnsChanged(newCollection, this.collection)) { + let columnLayout = new TableColumnLayout({}); + this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, newCollection, new Map()); + invalidationContext.sizeChanged = true; } - getColumnWidth(key: Key): number { - return this.columnLayout.getColumnWidth(key) ?? 0; - } - - getColumnMinWidth(key: Key): number { - let column = this.collection.columns.find(col => col.key === key); - if (!column) { - return 0; - } - return this.columnLayout.getColumnMinWidth(key); - } - - getColumnMaxWidth(key: Key): number { - let column = this.collection.columns.find(col => col.key === key); - if (!column) { - return 0; - } - return this.columnLayout.getColumnMaxWidth(key); - } - - // outside, where this is called, should call props.onColumnResizeStart... - startResize(key: Key): void { - this.resizingColumn = key; - } - - // only way to call props.onColumnResize with the new size outside of Layout is to send the result back - updateResizedColumns(key: Key, width: number): Map { - let newControlled = new Map(Array.from(this.controlledColumns).map(([key, entry]) => [key, entry.props.width])); - let newSizes = this.columnLayout.resizeColumnWidth(this.virtualizer.visibleRect.width, this.collection, newControlled, this.uncontrolledWidths, key, width); - - let map = new Map(Array.from(this.uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); - map.set(key, width); - this.uncontrolledWidths = map; - // invalidate still uses setState, should happen at the same time the parent - // component's state is processed as a result of props.onColumnResize - if (this.uncontrolledWidths.size > 0) { - this.virtualizer.invalidate({sizeChanged: true}); - } - return newSizes; - } - - endResize(): void { - this.resizingColumn = null; + super.validate(invalidationContext); } buildCollection(): LayoutNode[] { @@ -129,13 +89,6 @@ export class TableLayout extends ListLayout { } } - let [controlledColumns, uncontrolledColumns] = this.columnLayout.splitColumnsIntoControlledAndUncontrolled(this.collection.columns); - this.controlledColumns = controlledColumns; - this.uncontrolledColumns = uncontrolledColumns; - let colWidths = this.columnLayout.recombineColumns(this.collection.columns, this.uncontrolledWidths, uncontrolledColumns, controlledColumns); - - this.columnWidths = this.columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, this.collection, colWidths); - let header = this.buildHeader(); let body = this.buildBody(0); this.lastPersistedKeys = null; diff --git a/packages/@react-stately/table/src/TableColumnLayout.ts b/packages/@react-stately/table/src/TableColumnLayout.ts index 2c58b2f9c1f..1e2ba6f8a65 100644 --- a/packages/@react-stately/table/src/TableColumnLayout.ts +++ b/packages/@react-stately/table/src/TableColumnLayout.ts @@ -13,9 +13,7 @@ import { calculateColumnSizes, getMaxWidth, - getMinWidth, - isStatic, - parseFractionalUnit + getMinWidth } from './TableUtils'; import {ColumnSize, TableCollection} from '@react-types/table'; import {GridNode} from '@react-types/grid'; @@ -80,78 +78,25 @@ export class TableColumnLayout { return this.columnMaxWidths.get(key) ?? 0; } - resizeColumnWidth(tableWidth: number, collection: TableCollection, controlledWidths: Map, uncontrolledWidths: Map, col = null, width: number): Map { + resizeColumnWidth(collection: TableCollection, uncontrolledWidths: Map, col: Key, width: number): Map { let prevColumnWidths = this.columnWidths; - // resizing a column - let resizeIndex = Infinity; - let resizingChanged = new Map([...controlledWidths, ...uncontrolledWidths]); - let percentKeys = new Map(); - let frKeysToTheRight = new Map(); - let minWidths = new Map(); - // freeze columns to the left to their previous pixel value - collection.columns.forEach((column, i) => { - let frKey; - let frValue; - minWidths.set(column.key, this.getDefaultMinWidth(collection.columns[i])); - if (col !== column.key && !column.props.width && !isStatic(uncontrolledWidths.get(column.key))) { - // uncontrolled don't have props.width for us, so instead get from our state - frKey = column.key; - frValue = parseFractionalUnit(uncontrolledWidths.get(column.key) as string); - } else if (col !== column.key && !isStatic(column.props.width) && !uncontrolledWidths.get(column.key)) { - // controlledWidths will be the same in the collection - frKey = column.key; - frValue = parseFractionalUnit(column.props.width); - } else if (col !== column.key && column.props.width?.endsWith?.('%')) { - percentKeys.set(column.key, column.props.width); - } - // don't freeze columns to the right of the resizing one - if (resizeIndex < i) { - if (frKey) { - frKeysToTheRight.set(frKey, frValue); - } - return; - } - // we already know the new size of the resizing column - if (column.key === col) { - resizeIndex = i; - resizingChanged.set(column.key, Math.floor(width)); - return; - } - // freeze column to previous value - resizingChanged.set(column.key, prevColumnWidths.get(column.key)); - }); - - // predict pixels sizes for all columns based on resize - let columnWidths = calculateColumnSizes( - tableWidth, - collection.columns.map(col => ({...col.props, key: col.key})), - resizingChanged, - (i) => this.getDefaultWidth(collection.columns[i]), - (i) => this.getDefaultMinWidth(collection.columns[i]) - ); - - // set all new column widths for onResize event - // columns going in will be the same order as the columns coming out + let freeze = true; let newWidths = new Map(); - // set all column widths based on calculateColumnSize - columnWidths.forEach((width, index) => { - let key = collection.columns[index].key; - newWidths.set(key, width); - }); - // add FR's back as they were to columns to the right - Array.from(frKeysToTheRight).forEach(([key]) => { - newWidths.set(key, `${frKeysToTheRight.get(key)}fr`); - }); + width = Math.max(this.getColumnMinWidth(col), Math.min(this.getColumnMaxWidth(col), Math.floor(width))); - // put back in percents - Array.from(percentKeys).forEach(([key, width]) => { - // resizing locks a column to a px width - if (key === col) { - return; + collection.columns.forEach(column => { + if (column.key === col) { + newWidths.set(column.key, width); + freeze = false; + } else if (freeze) { + // freeze columns to the left to their previous pixel value + newWidths.set(column.key, prevColumnWidths.get(column.key)); + } else { + newWidths.set(column.key, column.props.width ?? uncontrolledWidths.get(column.key)); } - newWidths.set(key, width); }); + return newWidths; } diff --git a/packages/@react-stately/table/src/useTableColumnResizeState.ts b/packages/@react-stately/table/src/useTableColumnResizeState.ts index d3794b52c44..6bc80951a2a 100644 --- a/packages/@react-stately/table/src/useTableColumnResizeState.ts +++ b/packages/@react-stately/table/src/useTableColumnResizeState.ts @@ -47,7 +47,9 @@ export interface TableColumnResizeState { /** Key of the currently resizing column. */ resizingColumn: Key | null, /** A reference to the table state. */ - tableState: TableState + tableState: TableState, + /** A map of the current column widths. */ + columnWidths: Map } /** @@ -105,20 +107,18 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< }, [setResizingColumn]); let updateResizedColumns = useCallback((key: Key, width: number): Map => { - let newControlled = new Map(Array.from(controlledColumns).map(([key, entry]) => [key, entry.props.width])); - let newSizes = columnLayout.resizeColumnWidth(tableWidth, state.collection, newControlled, uncontrolledWidths, key, width); - + let newSizes = columnLayout.resizeColumnWidth(state.collection, uncontrolledWidths, key, width); let map = new Map(Array.from(uncontrolledColumns).map(([key]) => [key, newSizes.get(key)])); map.set(key, width); setUncontrolledWidths(map); return newSizes; - }, [controlledColumns, uncontrolledColumns, setUncontrolledWidths, tableWidth, columnLayout, state.collection, uncontrolledWidths]); + }, [uncontrolledColumns, setUncontrolledWidths, columnLayout, state.collection, uncontrolledWidths]); let endResize = useCallback(() => { setResizingColumn(null); }, [setResizingColumn]); - useMemo(() => + let columnWidths = useMemo(() => columnLayout.buildColumnWidths(tableWidth, state.collection, colWidths) , [tableWidth, state.collection, colWidths, columnLayout]); @@ -133,9 +133,11 @@ export function useTableColumnResizeState(props: TableColumnResizeStateProps< columnLayout.getColumnMinWidth(key), getColumnMaxWidth: (key: Key) => columnLayout.getColumnMaxWidth(key), - tableState: state + tableState: state, + columnWidths }), [ columnLayout, + columnWidths, resizingColumn, updateResizedColumns, startResize, diff --git a/packages/@react-stately/table/test/TableUtils.test.js b/packages/@react-stately/table/test/TableUtils.test.js index f6f51997b44..421ac788d9c 100644 --- a/packages/@react-stately/table/test/TableUtils.test.js +++ b/packages/@react-stately/table/test/TableUtils.test.js @@ -114,9 +114,7 @@ describe('TableUtils', () => { expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); let resizedColumns = layout.resizeColumnWidth( - 1000, collection, - new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), new Map([['height', 150], ['weight', 150]]), 'height', 200 @@ -132,9 +130,7 @@ describe('TableUtils', () => { expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 200], ['weight', 150], ['level', 450]])); resizedColumns = layout.resizeColumnWidth( - 1000, collection, - new Map([['name', 100], ['type', 100], ['level', '5fr']]), new Map([['height', 200], ['weight', 150]]), 'type', 50 @@ -164,9 +160,7 @@ describe('TableUtils', () => { expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); let resizedColumns = layout.resizeColumnWidth( - 1000, collection, - new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), new Map([['height', 150], ['weight', 150]]), 'height', 1000 @@ -197,9 +191,7 @@ describe('TableUtils', () => { expect(columns).toStrictEqual(new Map([['name', 100], ['type', 100], ['height', 150], ['weight', 150], ['level', 500]])); let resizedColumns = layout.resizeColumnWidth( - 1000, collection, - new Map([['name', '1fr'], ['type', '1fr'], ['level', '5fr']]), new Map([['height', 150], ['weight', 150]]), 'level', 400 From 535e1596d2f00c3e30cab2a64106435fdb696f01 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 7 Jun 2024 18:40:12 -0700 Subject: [PATCH 03/14] Add Virtualizer to React Aria Components --- .../@react-aria/tree/src/useTreeGridList.ts | 2 +- packages/@react-aria/utils/src/mergeRefs.ts | 4 +- .../virtualizer/src/ScrollView.tsx | 49 +++-- .../virtualizer/src/Virtualizer.tsx | 2 +- .../virtualizer/src/VirtualizerItem.tsx | 6 +- packages/@react-aria/virtualizer/src/index.ts | 2 +- .../@react-spectrum/card/src/BaseLayout.tsx | 6 +- .../table/src/TableViewBase.tsx | 3 +- .../@react-stately/layout/src/ListLayout.ts | 39 ++-- .../@react-stately/layout/src/TableLayout.ts | 89 ++++---- packages/@react-stately/layout/src/index.ts | 3 +- .../table/src/TableCollection.ts | 8 + packages/@react-types/table/src/index.d.ts | 2 + packages/react-aria-components/package.json | 3 + .../react-aria-components/src/Breadcrumbs.tsx | 7 +- .../react-aria-components/src/Collection.tsx | 82 ++++--- .../react-aria-components/src/GridList.tsx | 13 +- .../react-aria-components/src/ListBox.tsx | 18 +- packages/react-aria-components/src/Menu.tsx | 13 +- packages/react-aria-components/src/Table.tsx | 204 +++++++++++------- .../react-aria-components/src/TableLayout.ts | 28 +++ packages/react-aria-components/src/Tabs.tsx | 5 +- .../react-aria-components/src/TagGroup.tsx | 5 +- packages/react-aria-components/src/Tree.tsx | 11 +- .../react-aria-components/src/Virtualizer.tsx | 98 +++++++++ packages/react-aria-components/src/index.ts | 5 + .../stories/GridList.stories.tsx | 48 ++++- .../stories/ListBox.stories.tsx | 78 +++---- .../stories/Select.stories.tsx | 22 +- .../stories/Table.stories.tsx | 76 ++++++- .../stories/Tree.stories.tsx | 20 +- .../test/ListBox.test.js | 5 + 32 files changed, 688 insertions(+), 268 deletions(-) create mode 100644 packages/react-aria-components/src/TableLayout.ts create mode 100644 packages/react-aria-components/src/Virtualizer.tsx diff --git a/packages/@react-aria/tree/src/useTreeGridList.ts b/packages/@react-aria/tree/src/useTreeGridList.ts index 2f8c15058fd..995ff84bb0b 100644 --- a/packages/@react-aria/tree/src/useTreeGridList.ts +++ b/packages/@react-aria/tree/src/useTreeGridList.ts @@ -21,7 +21,7 @@ import {TreeState} from '@react-stately/tree'; export interface TreeGridListProps extends GridListProps {} export interface AriaTreeGridListProps extends Omit, 'keyboardNavigationBehavior'> {} -export interface AriaTreeGridListOptions extends Omit, 'children' | 'isVirtualized' | 'shouldFocusWrap'> { +export interface AriaTreeGridListOptions extends Omit, 'children' | 'shouldFocusWrap'> { /** * An optional keyboard delegate implementation for type to select, * to override the default. diff --git a/packages/@react-aria/utils/src/mergeRefs.ts b/packages/@react-aria/utils/src/mergeRefs.ts index ce448d83e39..146527446f6 100644 --- a/packages/@react-aria/utils/src/mergeRefs.ts +++ b/packages/@react-aria/utils/src/mergeRefs.ts @@ -15,8 +15,8 @@ import {ForwardedRef, MutableRefObject} from 'react'; /** * Merges multiple refs into one. Works with either callback or object refs. */ -export function mergeRefs(...refs: Array | MutableRefObject>): ForwardedRef { - if (refs.length === 1) { +export function mergeRefs(...refs: Array | MutableRefObject | null | undefined>): ForwardedRef { + if (refs.length === 1 && refs[0]) { return refs[0]; } diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index ae72120ee1d..36372f3ea3d 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -15,6 +15,7 @@ import {flushSync} from 'react-dom'; import {getScrollLeft} from './utils'; import React, { CSSProperties, + ForwardedRef, HTMLAttributes, ReactNode, RefObject, @@ -24,13 +25,13 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; interface ScrollViewProps extends HTMLAttributes { contentSize: Size, onVisibleRectChange: (rect: Rect) => void, - children: ReactNode, + children?: ReactNode, innerStyle?: CSSProperties, sizeToFit?: 'width' | 'height', onScrollStart?: () => void, @@ -38,11 +39,26 @@ interface ScrollViewProps extends HTMLAttributes { scrollDirection?: 'horizontal' | 'vertical' | 'both' } -function ScrollView(props: ScrollViewProps, ref: RefObject) { +function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { + ref = useObjectRef(ref); + let {scrollViewProps, contentProps} = useScrollView(props, ref); + + return ( +
+
+ {props.children} +
+
+ ); +} + +const ScrollViewForwardRef = React.forwardRef(ScrollView); +export {ScrollViewForwardRef as ScrollView}; + +export function useScrollView(props: ScrollViewProps, ref: RefObject) { let { contentSize, onVisibleRectChange, - children, innerStyle, sizeToFit, onScrollStart, @@ -51,8 +67,6 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { ...otherProps } = props; - let defaultRef = useRef(); - ref = ref || defaultRef; let state = useRef({ scrollTop: 0, scrollLeft: 0, @@ -114,6 +128,9 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { }); }, [props, direction, state, contentSize, onVisibleRectChange, onScrollStart, onScrollEnd]); + // Attach event directly to ref so RAC Virtualizer doesn't need to send props upward. + useEvent(ref, 'scroll', onScroll); + // eslint-disable-next-line arrow-body-style useEffect(() => { return () => { @@ -176,6 +193,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { let onResize = useCallback(() => { updateSize(flushSync); }, [updateSize]); + // Watch border-box instead of of content-box so that we don't go into // an infinite loop when scrollbars appear or disappear. useResizeObserver({ref, box: 'border-box', onResize}); @@ -207,14 +225,13 @@ function ScrollView(props: ScrollViewProps, ref: RefObject) { ...innerStyle }; - return ( -
-
- {children} -
-
- ); + return { + scrollViewProps: { + style, + ...otherProps + }, + contentProps: { + style: innerStyle + } + }; } - -const ScrollViewForwardRef = React.forwardRef(ScrollView); -export {ScrollViewForwardRef as ScrollView}; diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index dc54bda83b0..e9c11ea7a41 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -66,7 +66,7 @@ function Virtualizer(props: Virtualize ref.current.scrollLeft = rect.x; ref.current.scrollTop = rect.y; }, - persistedKeys: useMemo(() => focusedKey ? new Set([focusedKey]) : new Set(), [focusedKey]), + persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]), layoutOptions }); diff --git a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx index 5ac63a68417..2bebb184eee 100644 --- a/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx +++ b/packages/@react-aria/virtualizer/src/VirtualizerItem.tsx @@ -17,7 +17,7 @@ import {useLocale} from '@react-aria/i18n'; import {useVirtualizerItem, VirtualizerItemOptions} from './useVirtualizerItem'; interface VirtualizerItemProps extends Omit { - parent?: LayoutInfo, + parent?: LayoutInfo | null, className?: string, children: ReactNode } @@ -74,7 +74,9 @@ 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, - overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden', + // 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', opacity: layoutInfo.opacity, zIndex: layoutInfo.zIndex, transform: layoutInfo.transform, diff --git a/packages/@react-aria/virtualizer/src/index.ts b/packages/@react-aria/virtualizer/src/index.ts index bcd2d485fbc..9c86c63d46c 100644 --- a/packages/@react-aria/virtualizer/src/index.ts +++ b/packages/@react-aria/virtualizer/src/index.ts @@ -15,5 +15,5 @@ export type {VirtualizerItemOptions} from './useVirtualizerItem'; export {useVirtualizer, Virtualizer, VirtualizerContext} from './Virtualizer'; export {useVirtualizerItem} from './useVirtualizerItem'; export {VirtualizerItem, layoutInfoToStyle} from './VirtualizerItem'; -export {ScrollView} from './ScrollView'; +export {ScrollView, useScrollView} from './ScrollView'; export {getRTLOffsetType, getScrollLeft, setScrollLeft} from './utils'; diff --git a/packages/@react-spectrum/card/src/BaseLayout.tsx b/packages/@react-spectrum/card/src/BaseLayout.tsx index d2f6eb9e9c0..7062c185898 100644 --- a/packages/@react-spectrum/card/src/BaseLayout.tsx +++ b/packages/@react-spectrum/card/src/BaseLayout.tsx @@ -55,8 +55,8 @@ export class BaseLayout extends Layout, CardViewLayoutOptions> implem validate(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection as GridCollection; - this.isLoading = invalidationContext.layoutOptions.isLoading; - this.direction = invalidationContext.layoutOptions.direction; + this.isLoading = invalidationContext.layoutOptions?.isLoading || false; + this.direction = invalidationContext.layoutOptions?.direction || 'rtl'; this.buildCollection(invalidationContext); // Remove layout info that doesn't exist in new collection @@ -87,7 +87,7 @@ export class BaseLayout extends Layout, CardViewLayoutOptions> implem } getLayoutInfo(key: Key) { - return this.layoutInfos.get(key); + return this.layoutInfos.get(key)!; } getVisibleLayoutInfos(rect: Rect, excludePersistedKeys = false) { diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 68ca1d7982d..38de034ccbf 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -199,7 +199,8 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef = { indentationForItem?: (collection: Collection>, key: Key) => number, loaderHeight?: number, placeholderHeight?: number, + enableEmptyState?: boolean }; // A wrapper around LayoutInfo that supports hierarchy @@ -36,7 +37,7 @@ export interface LayoutNode { index?: number } -interface ListLayoutProps { +export interface ListLayoutProps { isLoading?: boolean } @@ -70,6 +71,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D protected invalidateEverything: boolean; protected loaderHeight: number; protected placeholderHeight: number; + protected enableEmptyState: boolean; protected lastValidRect: Rect; protected validRect: Rect; @@ -87,6 +89,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D 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 = []; @@ -136,7 +139,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return res; } - layoutIfNeeded(rect: Rect) { + protected layoutIfNeeded(rect: Rect) { if (!this.lastCollection) { return; } @@ -155,7 +158,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D } } - ensureLayoutInfo(key: Key) { + private ensureLayoutInfo(key: Key) { // 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. @@ -170,7 +173,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return false; } - isVisible(node: LayoutNode, rect: Rect) { + private isVisible(node: LayoutNode, rect: Rect) { return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || this.virtualizer.isPersistedKey(node.layoutInfo.key); } @@ -213,7 +216,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D this.invalidateEverything = false; } - buildCollection(): LayoutNode[] { + protected buildCollection(): LayoutNode[] { let y = this.padding; let skipped = 0; let nodes = []; @@ -227,7 +230,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D continue; } - let layoutNode = this.buildChild(node, 0, y); + let layoutNode = this.buildChild(node, 0, y, null); y = layoutNode.layoutInfo.rect.maxY; nodes.push(layoutNode); @@ -246,7 +249,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D y = loader.rect.maxY; } - if (nodes.length === 0) { + 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); @@ -259,7 +262,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return nodes; } - isValid(node: Node, y: number) { + protected isValid(node: Node, y: number) { let cached = this.layoutNodes.get(node.key); return ( !this.invalidateEverything && @@ -271,7 +274,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D ); } - buildChild(node: Node, x: number, y: number): LayoutNode { + protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { if (this.isValid(node, y)) { return this.layoutNodes.get(node.key); } @@ -279,7 +282,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D let layoutNode = this.buildNode(node, x, y); layoutNode.node = node; - layoutNode.layoutInfo.parentKey = node.parentKey ?? null; + layoutNode.layoutInfo.parentKey = parentKey ?? null; this.layoutInfos.set(layoutNode.layoutInfo.key, layoutNode.layoutInfo); if (layoutNode.header) { this.layoutInfos.set(layoutNode.header.key, layoutNode.header); @@ -289,22 +292,22 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return layoutNode; } - buildNode(node: Node, x: number, y: number): LayoutNode { + protected buildNode(node: Node, x: number, y: number): LayoutNode { switch (node.type) { case 'section': return this.buildSection(node, x, y); case 'item': return this.buildItem(node, x, y); case 'header': - return this.buildHeader(node, x, y); + return this.buildSectionHeader(node, x, y); } } - buildSection(node: Node, x: number, y: number): LayoutNode { + private buildSection(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; let header = null; if (node.rendered) { - let headerNode = this.buildHeader(node, x, y); + let headerNode = this.buildSectionHeader(node, x, y); header = headerNode.layoutInfo; header.key += ':header'; header.parentKey = node.key; @@ -327,7 +330,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D continue; } - let layoutNode = this.buildChild(child, x, y); + let layoutNode = this.buildChild(child, x, y, layoutInfo.key); y = layoutNode.layoutInfo.rect.maxY; children.push(layoutNode); @@ -348,7 +351,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D }; } - buildHeader(node: Node, x: number, y: number): LayoutNode { + private buildSectionHeader(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; let rectHeight = this.headingHeight; let isEstimated = false; @@ -385,7 +388,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D }; } - buildItem(node: Node, x: number, y: number): LayoutNode { + private buildItem(node: Node, x: number, y: number): LayoutNode { let width = this.virtualizer.visibleRect.width; let rectHeight = this.rowHeight; let isEstimated = false; @@ -453,7 +456,7 @@ export class ListLayout extends Layout, ListLayoutProps> implements D return false; } - updateLayoutNode(key: Key, oldLayoutInfo: LayoutInfo, newLayoutInfo: LayoutInfo) { + private updateLayoutNode(key: Key, oldLayoutInfo: LayoutInfo, newLayoutInfo: LayoutInfo) { let n = this.layoutNodes.get(key); if (n) { // Invalidate by reseting validRect. diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 7cb66951ca4..11080eb5b5f 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -10,17 +10,16 @@ * governing permissions and limitations under the License. */ -import {ColumnSize, TableCollection} from '@react-types/table'; 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} from './ListLayout'; +import {LayoutNode, ListLayout, ListLayoutOptions, ListLayoutProps} from './ListLayout'; +import {TableCollection} from '@react-types/table'; import {TableColumnLayout} from '@react-stately/table'; -type TableLayoutOptions = ListLayoutOptions & { - columnLayout: TableColumnLayout, - initialCollection: TableCollection +export interface TableLayoutOptions extends ListLayoutOptions { + scrollContainer?: 'table' | 'body' } export interface TableLayoutProps extends ListLayoutProps { @@ -32,15 +31,15 @@ export class TableLayout extends ListLayout { lastCollection: TableCollection; columnWidths: Map; stickyColumnIndices: number[]; - wasLoading = false; isLoading = false; lastPersistedKeys: Set = null; persistedIndices: Map = new Map(); + scrollContainer: 'table' | 'body'; private disableSticky: boolean; constructor(options: TableLayoutOptions) { super(options); - this.collection = options.initialCollection; + this.scrollContainer = options.scrollContainer || 'table'; this.stickyColumnIndices = []; this.disableSticky = this.checkChrome105(); } @@ -69,15 +68,14 @@ export class TableLayout extends ListLayout { let columnLayout = new TableColumnLayout({}); this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, newCollection, new Map()); invalidationContext.sizeChanged = true; - } + } super.validate(invalidationContext); } - buildCollection(): LayoutNode[] { + 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.wasLoading = this.isLoading; this.isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; this.stickyColumnIndices = []; @@ -89,8 +87,8 @@ export class TableLayout extends ListLayout { } } - let header = this.buildHeader(); - let body = this.buildBody(0); + let header = this.buildColumnHeader(); + let body = this.buildBody(this.scrollContainer === 'body' ? 0 : header.layoutInfo.rect.height); this.lastPersistedKeys = null; body.layoutInfo.rect.width = Math.max(header.layoutInfo.rect.width, body.layoutInfo.rect.width); @@ -101,16 +99,18 @@ export class TableLayout extends ListLayout { ]; } - buildHeader(): LayoutNode { + private buildColumnHeader(): LayoutNode { let rect = new Rect(0, 0, 0, 0); - let layoutInfo = new LayoutInfo('header', 'header', rect); + let layoutInfo = new LayoutInfo('header', this.collection.head?.key ?? 'header', rect); + layoutInfo.isSticky = true; + layoutInfo.zIndex = 1; let y = 0; let width = 0; let children: LayoutNode[] = []; for (let headerRow of this.collection.headerRows) { - let layoutNode = this.buildChild(headerRow, 0, y); - layoutNode.layoutInfo.parentKey = 'header'; + let layoutNode = this.buildChild(headerRow, 0, y, layoutInfo.key); + layoutNode.layoutInfo.parentKey = layoutInfo.key; y = layoutNode.layoutInfo.rect.maxY; width = Math.max(width, layoutNode.layoutInfo.rect.width); layoutNode.index = children.length; @@ -120,7 +120,7 @@ export class TableLayout extends ListLayout { rect.width = width; rect.height = y; - this.layoutInfos.set('header', layoutInfo); + this.layoutInfos.set(layoutInfo.key, layoutInfo); return { layoutInfo, @@ -129,14 +129,14 @@ export class TableLayout extends ListLayout { }; } - buildHeaderRow(headerRow: GridNode, x: number, y: number): LayoutNode { + private buildHeaderRow(headerRow: GridNode, x: number, y: number): LayoutNode { let rect = new Rect(0, y, 0, 0); let row = new LayoutInfo('headerrow', headerRow.key, rect); let height = 0; let columns: LayoutNode[] = []; for (let cell of getChildNodes(headerRow, this.collection)) { - let layoutNode = this.buildChild(cell, x, y); + let layoutNode = this.buildChild(cell, x, y, row.key); layoutNode.layoutInfo.parentKey = row.key; x = layoutNode.layoutInfo.rect.maxX; height = Math.max(height, layoutNode.layoutInfo.rect.height); @@ -159,7 +159,7 @@ export class TableLayout extends ListLayout { }; } - setChildHeights(children: LayoutNode[], height: number) { + private setChildHeights(children: LayoutNode[], height: number) { for (let child of children) { if (child.layoutInfo.rect.height !== height) { // Need to copy the layout info before we mutate it. @@ -172,7 +172,7 @@ export class TableLayout extends ListLayout { } // used to get the column widths when rendering to the DOM - getRenderedColumnWidth(node: GridNode) { + private getRenderedColumnWidth(node: GridNode) { let colspan = node.colspan ?? 1; let colIndex = node.colIndex ?? node.index; let width = 0; @@ -186,7 +186,7 @@ export class TableLayout extends ListLayout { return width; } - getEstimatedHeight(node: GridNode, width: number, height: number, estimatedHeight: number) { + private getEstimatedHeight(node: GridNode, width: number, height: number, estimatedHeight: number) { let isEstimated = false; // If no explicit height is available, use an estimated height. @@ -207,7 +207,7 @@ export class TableLayout extends ListLayout { return {height, isEstimated}; } - buildColumn(node: GridNode, x: number, y: number): LayoutNode { + private 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); @@ -222,15 +222,15 @@ export class TableLayout extends ListLayout { }; } - buildBody(y: number): LayoutNode { + private buildBody(y: number): LayoutNode { let rect = new Rect(0, y, 0, 0); - let layoutInfo = new LayoutInfo('rowgroup', 'body', rect); + let layoutInfo = new LayoutInfo('rowgroup', this.collection.body.key, rect); let startY = y; let skipped = 0; let width = 0; let children: LayoutNode[] = []; - for (let [i, node] of [...this.collection].entries()) { + 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. @@ -240,8 +240,8 @@ export class TableLayout extends ListLayout { continue; } - let layoutNode = this.buildChild(node, 0, y); - layoutNode.layoutInfo.parentKey = 'body'; + let layoutNode = this.buildChild(node, 0, y, layoutInfo.key); + layoutNode.layoutInfo.parentKey = layoutInfo.key; layoutNode.index = i; y = layoutNode.layoutInfo.rect.maxY; width = Math.max(width, layoutNode.layoutInfo.rect.width); @@ -258,16 +258,16 @@ export class TableLayout extends ListLayout { // 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 = 'body'; + 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) { + } else if (children.length === 0 && 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 = 'body'; + empty.parentKey = layoutInfo.key; empty.isSticky = !this.disableSticky; this.layoutInfos.set('empty', empty); children.push({layoutInfo: empty, validRect: empty.rect}); @@ -278,7 +278,7 @@ export class TableLayout extends ListLayout { rect.width = width; rect.height = y - startY; - this.layoutInfos.set('body', layoutInfo); + this.layoutInfos.set(layoutInfo.key, layoutInfo); return { layoutInfo, @@ -287,7 +287,7 @@ export class TableLayout extends ListLayout { }; } - buildNode(node: GridNode, x: number, y: number): LayoutNode { + protected buildNode(node: GridNode, x: number, y: number): LayoutNode { switch (node.type) { case 'headerrow': return this.buildHeaderRow(node, x, y); @@ -303,7 +303,7 @@ export class TableLayout extends ListLayout { } } - buildRow(node: GridNode, x: number, y: number): LayoutNode { + private buildRow(node: GridNode, x: number, y: number): LayoutNode { let rect = new Rect(x, y, 0, 0); let layoutInfo = new LayoutInfo('row', node.key, rect); @@ -320,7 +320,7 @@ export class TableLayout extends ListLayout { x += layoutNode.layoutInfo.rect.width; } } else { - let layoutNode = this.buildChild(child, x, y); + let layoutNode = this.buildChild(child, x, y, layoutInfo.key); x = layoutNode.layoutInfo.rect.maxX; height = Math.max(height, layoutNode.layoutInfo.rect.height); layoutNode.index = i; @@ -331,7 +331,7 @@ export class TableLayout extends ListLayout { this.setChildHeights(children, height); - rect.width = this.layoutInfos.get('header').rect.width; + rect.width = this.layoutInfos.get(this.collection.head?.key ?? 'header').rect.width; rect.height = height + 1; // +1 for bottom border return { @@ -341,7 +341,7 @@ export class TableLayout extends ListLayout { }; } - buildCell(node: GridNode, x: number, y: number): LayoutNode { + private 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); @@ -367,11 +367,7 @@ export class TableLayout extends ListLayout { // If layout hasn't yet been done for the requested rect, union the // new rect with the existing valid rect, and recompute. - if (!this.validRect.containsRect(rect) && this.lastCollection) { - this.lastValidRect = this.validRect; - this.validRect = this.validRect.union(rect); - this.rootNodes = this.buildCollection(); - } + this.layoutIfNeeded(rect); let res: LayoutInfo[] = []; @@ -384,7 +380,7 @@ export class TableLayout extends ListLayout { return res; } - addVisibleLayoutInfos(res: LayoutInfo[], node: LayoutNode, rect: Rect) { + private addVisibleLayoutInfos(res: LayoutInfo[], node: LayoutNode, rect: Rect) { if (!node.children || node.children.length === 0) { return; } @@ -476,7 +472,7 @@ export class TableLayout extends ListLayout { } } - binarySearch(items: LayoutNode[], point: Point, axis: 'x' | 'y') { + private binarySearch(items: LayoutNode[], point: Point, axis: 'x' | 'y') { let low = 0; let high = items.length - 1; while (low <= high) { @@ -495,7 +491,7 @@ export class TableLayout extends ListLayout { return Math.max(0, Math.min(items.length - 1, low)); } - buildPersistedIndices() { + private buildPersistedIndices() { if (this.virtualizer.persistedKeys === this.lastPersistedKeys) { return; } @@ -513,7 +509,7 @@ export class TableLayout extends ListLayout { let indices = this.persistedIndices.get(layoutInfo.parentKey); if (!indices) { // stickyColumnIndices are always persisted along with any cells from persistedKeys. - indices = collectionNode.type === 'cell' || collectionNode.type === 'column' ? [...this.stickyColumnIndices] : []; + indices = collectionNode?.type === 'cell' || collectionNode?.type === 'column' ? [...this.stickyColumnIndices] : []; this.persistedIndices.set(layoutInfo.parentKey, indices); } @@ -549,6 +545,7 @@ export class TableLayout extends ListLayout { 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 7197e7e5fb8..f2cee0c4eff 100644 --- a/packages/@react-stately/layout/src/index.ts +++ b/packages/@react-stately/layout/src/index.ts @@ -9,6 +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, LayoutNode} from './ListLayout'; +export type {ListLayoutOptions, ListLayoutProps, LayoutNode} from './ListLayout'; +export type {TableLayoutOptions, TableLayoutProps} from './TableLayout'; export {ListLayout} from './ListLayout'; export {TableLayout} from './TableLayout'; diff --git a/packages/@react-stately/table/src/TableCollection.ts b/packages/@react-stately/table/src/TableCollection.ts index 7ae3e2ef9a5..12d8f5cb7fd 100644 --- a/packages/@react-stately/table/src/TableCollection.ts +++ b/packages/@react-stately/table/src/TableCollection.ts @@ -317,6 +317,14 @@ export class TableCollection extends GridCollection implements ITableColle return this.getItem(keys[idx]); } + getChildren(key: Key): Iterable> { + if (key === this.body.key) { + return this.body.childNodes; + } + + return super.getChildren(key); + } + getTextValue(key: Key): string { let row = this.getItem(key); if (!row) { diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index e9bb24ad28e..c6fe0c29fe7 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -167,6 +167,8 @@ export interface TableCollection extends GridCollection { columns: GridNode[], /** A set of column keys that serve as the [row header](https://www.w3.org/TR/wai-aria-1.1/#rowheader). */ rowHeaderColumnKeys: Set, + /** The node that makes up the header of the table. */ + head?: GridNode, /** The node that makes up the body of the table. */ body: GridNode } diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index 20622dc661d..a14516f2d52 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -46,10 +46,13 @@ "@react-aria/toolbar": "3.0.0-beta.5", "@react-aria/tree": "3.0.0-alpha.1", "@react-aria/utils": "^3.24.1", + "@react-aria/virtualizer": "^3.10.1", "@react-stately/color": "^3.6.1", + "@react-stately/layout": "^3.13.9", "@react-stately/menu": "^3.7.1", "@react-stately/table": "^3.11.8", "@react-stately/utils": "^3.10.1", + "@react-stately/virtualizer": "^3.7.1", "@react-types/color": "3.0.0-beta.25", "@react-types/form": "^3.7.4", "@react-types/grid": "^3.2.6", diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 4dd63f71d71..d456e71ec38 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -11,12 +11,12 @@ */ import {AriaBreadcrumbsProps} from 'react-aria'; import {Collection, Node} from 'react-stately'; -import {CollectionChildren, CollectionProps, createLeafComponent, useCollection} from './Collection'; +import {CollectionProps, CollectionRendererContext, createLeafComponent, useCollection} from './Collection'; import {ContextValue, forwardRefType, RenderProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps} from '@react-aria/utils'; import {Key} from '@react-types/shared'; import {LinkContext} from './Link'; -import React, {createContext, ForwardedRef, forwardRef, ReactNode, RefObject} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, ReactNode, RefObject, useContext} from 'react'; export interface BreadcrumbsProps extends Omit, 'disabledKeys'>, AriaBreadcrumbsProps, StyleProps, SlotProps { /** Whether the breadcrumbs are disabled. */ @@ -47,6 +47,7 @@ interface BreadcrumbsInnerProps { } function BreadcrumbsInner({props, collection, breadcrumbsRef: ref}: BreadcrumbsInnerProps) { + let {CollectionRoot} = useContext(CollectionRendererContext); return (
    ({props, collection, breadcrumbsRef: style={props.style} className={props.className ?? 'react-aria-Breadcrumbs'}> - +
); diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 1defbefd6e5..a9d7c759737 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -9,12 +9,13 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {CollectionBase, Key} from '@react-types/shared'; +import {CollectionBase, DropTargetDelegate, Key, LayoutDelegate} from '@react-types/shared'; import {createPortal} from 'react-dom'; import {forwardRefType, StyleProps} from './utils'; import {Collection as ICollection, Node, SelectionBehavior, SelectionMode, SectionProps as SharedSectionProps} from 'react-stately'; import {mergeProps, useIsSSR} from 'react-aria'; -import React, {cloneElement, createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef} from 'react'; +import React, {cloneElement, createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo, useRef} from 'react'; +import {useLayoutEffect} from '@react-aria/utils'; import {useSyncExternalStore as useSyncExternalStoreShim} from 'use-sync-external-store/shim/index.js'; // This Collection implementation is perhaps a little unusual. It works by rendering the React tree into a @@ -228,7 +229,7 @@ class BaseNode { } removeChild(child: ElementNode) { - if (child.parentNode !== this) { + if (child.parentNode !== this || !this.ownerDocument.isMounted) { return; } @@ -509,6 +510,7 @@ export class Document = BaseCollection> extend isSSR = false; nodeId = 0; nodesByProps = new WeakMap>(); + isMounted = true; private collection: C; private collectionMutated: boolean; private mutatedNodes: Set> = new Set(); @@ -523,7 +525,7 @@ export class Document = BaseCollection> extend } get isConnected() { - return true; + return this.isMounted; } createElement(type: string) { @@ -765,6 +767,14 @@ export function useCollectionDocument { + document.isMounted = true; + return () => { + // Mark unmounted so we can skip all of the collection updates caused by + // React calling removeChild on every item in the collection. + document.isMounted = false; + }; + }, [document]); return {collection, document}; } @@ -915,7 +925,11 @@ export function Collection(props: CollectionProps): JSX.Ele export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef) => JSX.Element): (props: P & React.RefAttributes) => React.ReactElement | null; export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => JSX.Element): (props: P & React.RefAttributes) => React.ReactElement | null; export function createLeafComponent

(type: string, render: (props: P, ref: ForwardedRef, node?: any) => JSX.Element) { - let Component = ({node}) => render(node.props, node.props.ref, node); + let Component = ({node}) => ( + + {render(node.props, node.props.ref, node)} + + ); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let isShallow = useContext(ShallowRenderContext); if (!isShallow) { @@ -925,11 +939,7 @@ export function createLeafComponent

(type: s return render(props, ref); } - return useSSRCollectionNode(type, props, ref, 'children' in props ? props.children : null, null, node => ( - - - - )); + return useSSRCollectionNode(type, props, ref, 'children' in props ? props.children : null, null, node => ); }); // @ts-ignore Result.displayName = render.name; @@ -947,24 +957,42 @@ export function createBranchComponent(collection: ICollection>, parent?: Node) => ReactNode; -const useDefaultCollectionRenderer: CollectionRenderer = (collection, parent) => { - return useCachedChildren({ - items: parent ? collection.getChildren!(parent.key) : collection, - children(child) { - return child.render!(child); - } - }); -}; - -export const CollectionRendererContext = createContext(useDefaultCollectionRenderer); +export interface CollectionBranchProps { + collection: ICollection>, + parent: Node +} -export interface CollectionChildrenProps { - collection: ICollection>, - parent?: Node +export interface CollectionRootProps extends HTMLAttributes { + collection: ICollection>, + focusedKey?: Key | null, + scrollRef?: RefObject } -export function CollectionChildren(props: CollectionChildrenProps) { - let renderer = useContext(CollectionRendererContext); - return renderer(props.collection, props.parent); +export interface CollectionRenderer { + isVirtualized?: boolean, + layoutDelegate?: LayoutDelegate, + dropTargetDelegate?: DropTargetDelegate, + CollectionRoot: React.ComponentType, + CollectionBranch: React.ComponentType } + +const DefaultCollectionRenderer: CollectionRenderer = { + CollectionRoot({collection}) { + return useCachedChildren({ + items: collection, + children(child) { + return child.render!(child); + } + }); + }, + CollectionBranch({collection, parent}) { + return useCachedChildren({ + items: collection.getChildren!(parent.key), + children(child) { + return child.render!(child); + } + }); + } +}; + +export const CollectionRendererContext = createContext(DefaultCollectionRenderer); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 2755c8bec76..5ba6ac3beed 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -13,7 +13,7 @@ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicat import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {Collection, DraggableCollectionState, DroppableCollectionState, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; -import {CollectionChildren, CollectionProps, createLeafComponent, ItemRenderProps, useCollection} from './Collection'; +import {CollectionProps, CollectionRendererContext, createLeafComponent, ItemRenderProps, useCollection} from './Collection'; import {ContextValue, DEFAULT_SLOT, forwardRefType, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; @@ -91,6 +91,7 @@ interface GridListInnerProps { function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props; + let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext); let state = useListState({ ...props, collection, @@ -107,16 +108,18 @@ function GridListInner({props, collection, gridListRef: ref}: ref, disabledKeys, disabledBehavior, + layoutDelegate, layout, direction }) - ), [collection, ref, layout, disabledKeys, disabledBehavior, collator, direction]); + ), [collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]); let {gridProps} = useGridList({ ...props, keyboardDelegate, // Only tab navigation is supported in grid layout. - keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior + keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior, + isVirtualized }, state, ref); let selectionManager = state.selectionManager; @@ -166,7 +169,7 @@ function GridListInner({props, collection, gridListRef: ref}: disabledBehavior: selectionManager.disabledBehavior, ref }); - let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction}); + let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction}); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, dropTargetDelegate @@ -225,7 +228,7 @@ function GridListInner({props, collection, gridListRef: ref}: [DropIndicatorContext, {render: GridListDropIndicatorWrapper}] ]}> {isListDroppable && } - + {emptyState} {dragPreview} diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index a4a3eb41c83..99ce359e967 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -11,7 +11,7 @@ */ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; -import {CollectionChildren, CollectionDocumentContext, CollectionPortal, CollectionProps, createLeafComponent, ItemRenderProps, SectionContext, SectionProps, useCollection} from './Collection'; +import {CollectionDocumentContext, CollectionPortal, CollectionProps, CollectionRendererContext, createLeafComponent, ItemRenderProps, SectionContext, SectionProps, useCollection} from './Collection'; import {ContextValue, forwardRefType, HiddenContext, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, useListState} from 'react-stately'; @@ -132,6 +132,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner let {direction} = useLocale(); let {disabledBehavior, disabledKeys} = selectionManager; let collator = useCollator({usage: 'search', sensitivity: 'base'}); + let {isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, CollectionRoot} = useContext(CollectionRendererContext); let keyboardDelegate = useMemo(() => ( props.keyboardDelegate || new ListKeyboardDelegate({ collection, @@ -141,14 +142,16 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner disabledBehavior, layout, orientation, - direction + direction, + layoutDelegate }) - ), [collection, collator, listBoxRef, disabledBehavior, disabledKeys, orientation, direction, props.keyboardDelegate, layout]); + ), [collection, collator, listBoxRef, disabledBehavior, disabledKeys, orientation, direction, props.keyboardDelegate, layout, layoutDelegate]); let {listBoxProps} = useListBox({ ...props, shouldSelectOnPressUp: isListDraggable || props.shouldSelectOnPressUp, - keyboardDelegate + keyboardDelegate, + isVirtualized }, state, listBoxRef); let dragHooksProvided = useRef(isListDraggable); @@ -189,7 +192,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner selectionManager }); - let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, listBoxRef, {orientation, layout, direction}); + let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, listBoxRef, {orientation, layout, direction}); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, dropTargetDelegate @@ -250,7 +253,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner [DropIndicatorContext, {render: ListBoxDropIndicatorWrapper}], [SectionContext, {render: ListBoxSection}] ]}> - + {emptyState} {dragPreview} @@ -261,6 +264,7 @@ function ListBoxInner({state, props, listBoxRef}: ListBoxInner function ListBoxSection(props: SectionProps, ref: ForwardedRef, section: Node) { let state = useContext(ListStateContext)!; + let {CollectionBranch} = useContext(CollectionRendererContext); let [headingRef, heading] = useSlot(); let {headingProps, groupProps} = useListBoxSection({ heading, @@ -280,7 +284,7 @@ function ListBoxSection(props: SectionProps, ref: Forwarded {...renderProps} ref={ref}> - + ); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index dda7fd0bb11..461b4832cd6 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -12,7 +12,7 @@ import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria'; -import {BaseCollection, CollectionChildren, CollectionProps, createBranchComponent, createLeafComponent, ItemRenderProps, SectionContext, SectionProps, useCollection} from './Collection'; +import {BaseCollection, CollectionProps, CollectionRendererContext, createBranchComponent, createLeafComponent, ItemRenderProps, SectionContext, SectionProps, useCollection} from './Collection'; import {MenuTriggerProps as BaseMenuTriggerProps, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {ContextValue, forwardRefType, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils'; @@ -97,6 +97,7 @@ const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, item) => { + let {CollectionBranch} = useContext(CollectionRendererContext); let state = useContext(MenuStateContext)!; let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; let submenuTriggerState = useSubmenuTriggerState({triggerKey: item.key}, rootMenuTriggerState); @@ -125,7 +126,7 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg ...popoverProps }] ]}> - + {props.children[1]} ); @@ -159,7 +160,8 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne children: undefined }); let [popoverContainer, setPopoverContainer] = useState(null); - let {menuProps} = useMenu(props, state, ref); + let {isVirtualized, CollectionRoot} = useContext(CollectionRendererContext); + let {menuProps} = useMenu({...props, isVirtualized}, state, ref); let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; let popoverContext = useContext(PopoverContext)!; @@ -209,7 +211,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SubmenuTriggerContext, {parentMenuRef: ref}], [MenuItemContext, null] ]}> - +
@@ -225,6 +227,7 @@ export {_Menu as Menu}; function MenuSection(props: SectionProps, ref: ForwardedRef, section: Node) { let state = useContext(MenuStateContext)!; + let {CollectionBranch} = useContext(CollectionRendererContext); let [headingRef, heading] = useSlot(); let {headingProps, groupProps} = useMenuSection({ heading, @@ -244,7 +247,7 @@ function MenuSection(props: SectionProps, ref: ForwardedRef {...renderProps} ref={ref}> - + ); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index d62536ca4e2..b22770eb252 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,5 +1,5 @@ import {AriaLabelingProps, HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; -import {BaseCollection, CollectionChildren, CollectionContext, CollectionProps, createBranchComponent, createLeafComponent, ItemRenderProps, NodeValue, useCachedChildren, useCollection, useCollectionChildren} from './Collection'; +import {BaseCollection, CollectionContext, CollectionProps, CollectionRendererContext, createBranchComponent, createLeafComponent, ItemRenderProps, NodeValue, useCachedChildren, useCollection, useCollectionChildren} from './Collection'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; @@ -8,7 +8,7 @@ import {ContextValue, DEFAULT_SLOT, DOMProps, Provider, RenderProps, ScrollableP import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Node, SelectionBehavior, SelectionMode, SortDirection, TableState, useTableColumnResizeState, useTableState} from 'react-stately'; import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContext, DropIndicatorProps} from './useDragAndDrop'; import {DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useHover, useLocale, useLocalizedStringFormatter, useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox, useVisuallyHidden} from 'react-aria'; -import {filterDOMProps, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, isScrollable, mergeRefs, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -18,6 +18,7 @@ import ReactDOM from 'react-dom'; class TableCollection extends BaseCollection implements ITableCollection { headerRows: GridNode[] = []; columns: GridNode[] = []; + rows: GridNode[] = []; rowHeaderColumnKeys: Set = new Set(); head: NodeValue = new NodeValue('tableheader', -1); body: NodeValue = new NodeValue('tablebody', -2); @@ -39,6 +40,7 @@ class TableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection extends BaseCollection implements ITableCollection, + scrollRef: RefObject, // Dependency inject useTableColumnResizeState so it doesn't affect bundle size unless you're using ResizableTableContainer. useTableColumnResizeState: typeof useTableColumnResizeState, onResizeStart?: (widths: Map) => void, @@ -209,31 +213,50 @@ export interface ResizableTableContainerProps extends DOMProps, ScrollableProps< } function ResizableTableContainer(props: ResizableTableContainerProps, ref: ForwardedRef) { - let objectRef = useObjectRef(ref); + let containerRef = useObjectRef(ref); + let tableRef = useRef(null); + let scrollRef = useRef(null); let [width, setWidth] = useState(0); + + useLayoutEffect(() => { + // Walk up the DOM from the Table to the ResizableTableContainer and stop + // when we reach the first scrollable element. This is what we'll measure + // to determine column widths (important due to width of scrollbars). + // This will usually be the ResizableTableContainer for native tables, and + // the Table itself for virtualized tables. + let table = tableRef.current as HTMLElement | null; + while (table && table !== containerRef.current && !isScrollable(table)) { + table = table.parentElement; + } + scrollRef.current = table; + }, [containerRef]); + useResizeObserver({ - ref: objectRef, + ref: scrollRef, + box: 'border-box', onResize() { - setWidth(objectRef.current?.clientWidth ?? 0); + setWidth(scrollRef.current?.clientWidth ?? 0); } }); useLayoutEffect(() => { - setWidth(objectRef.current?.clientWidth ?? 0); - }, [objectRef]); + setWidth(scrollRef.current?.clientWidth ?? 0); + }, []); let ctx = useMemo(() => ({ + tableRef, + scrollRef, tableWidth: width, useTableColumnResizeState, onResizeStart: props.onResizeStart, onResize: props.onResize, onResizeEnd: props.onResizeEnd - }), [width, props.onResizeStart, props.onResize, props.onResizeEnd]); + }), [tableRef, width, props.onResizeStart, props.onResize, props.onResizeEnd]); return (
@@ -294,6 +317,8 @@ export interface TableProps extends Omit, 'children'>, Sty function Table(props: TableProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, TableContext); + let tableContainerContext = useContext(ResizableTableContainerContext); + ref = useObjectRef(useMemo(() => mergeRefs(ref, tableContainerContext?.tableRef), [ref, tableContainerContext?.tableRef])); let initialCollection = useMemo(() => new TableCollection(), []); let {portal, collection} = useCollection(props, initialCollection); let state = useTableState({ @@ -302,7 +327,12 @@ function Table(props: TableProps, ref: ForwardedRef) { children: undefined }); - let {gridProps} = useTable(props, state, ref); + let {isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, CollectionRoot} = useContext(CollectionRendererContext); + let {gridProps} = useTable({ + ...props, + layoutDelegate, + isVirtualized + }, state, ref); let {dragAndDropHooks} = props; let selectionManager = state.selectionManager; @@ -350,9 +380,10 @@ function Table(props: TableProps, ref: ForwardedRef) { collection, disabledKeys: selectionManager.disabledKeys, disabledBehavior: selectionManager.disabledBehavior, - ref + ref, + layoutDelegate }); - let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref); + let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, dropTargetDelegate @@ -385,19 +416,22 @@ function Table(props: TableProps, ref: ForwardedRef) { }), [selectionBehavior, selectionMode, disallowEmptySelection, hasDragHooks]); let style = renderProps.style; - let tableContainerContext = useContext(ResizableTableContainerContext); let layoutState: TableColumnResizeState | null = null; if (tableContainerContext) { layoutState = tableContainerContext.useTableColumnResizeState({ tableWidth: tableContainerContext.tableWidth }, state); - style = { - ...style, - tableLayout: 'fixed', - width: 'fit-content' - }; + if (!isVirtualized) { + style = { + ...style, + tableLayout: 'fixed', + width: 'fit-content' + }; + } } + let ElementType = useElementType('table'); + return ( <> @@ -411,7 +445,7 @@ function Table(props: TableProps, ref: ForwardedRef) { [DropIndicatorContext, {render: TableDropIndicatorWrapper}] ]}> - ) { data-drop-target={isRootDropTarget || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined}> - -
+ +
{dragPreview} @@ -439,15 +473,9 @@ function Table(props: TableProps, ref: ForwardedRef) { const _Table = forwardRef(Table); export {_Table as Table}; -// Separate component from Table so we have the collection during SSR. -function TableContents() { - let collection = useContext(TableStateContext)!.collection as TableCollection; - return ( - <> - {collection.head.render?.(collection.head)} - {collection.body.render?.(collection.body)} - - ); +function useElementType(element: E): E | 'div' { + let {isVirtualized} = useContext(CollectionRendererContext); + return isVirtualized ? 'div' : element; } export interface TableOptionsContextValue { @@ -497,17 +525,18 @@ export const TableHeader = /*#__PURE__*/ createBranchComponent( } }, []) }); - + + let THead = useElementType('thead'); let {rowGroupProps} = useTableRowGroup(); return ( - {headerRows} - + ); }, props => useCollectionChildren({ @@ -520,11 +549,13 @@ export const TableHeader = /*#__PURE__*/ createBranchComponent( function TableHeaderRow({item}: {item: GridNode}) { let ref = useRef(null); let state = useContext(TableStateContext)!; - let {rowProps} = useTableHeaderRow({node: item}, state, ref); + let {isVirtualized, CollectionBranch} = useContext(CollectionRendererContext); + let {rowProps} = useTableHeaderRow({node: item, isVirtualized}, state, ref); let {checkboxProps} = useTableSelectAllCheckbox(state); + let TR = useElementType('tr'); return ( - + }) { } }] ]}> - + - + ); } @@ -605,8 +636,9 @@ export interface ColumnProps extends RenderProps { export const Column = /*#__PURE__*/ createLeafComponent('column', (props: ColumnProps, forwardedRef: ForwardedRef, column: GridNode) => { let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; + let {isVirtualized} = useContext(CollectionRendererContext); let {columnHeaderProps} = useTableColumnHeader( - {node: column}, + {node: column, isVirtualized}, state, ref ); @@ -658,8 +690,10 @@ export const Column = /*#__PURE__*/ createLeafComponent('column', (props: Column style = {...style, width: layoutState.getColumnWidth(column.key)}; } + let TH = useElementType('th'); + return ( - {renderProps.children} - + ); }); @@ -835,7 +869,7 @@ export interface TableBodyProps extends CollectionProps, StyleRenderProps< export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', (props: TableBodyProps, ref: ForwardedRef) => { let state = useContext(TableStateContext)!; let collection = state.collection; - + let {CollectionBranch} = useContext(CollectionRendererContext); let {dragAndDropHooks, dropState} = useContext(DragAndDropContext); let isDroppable = !!dragAndDropHooks?.useDroppableCollectionState && !dropState?.isDisabled; let isRootDropTarget = isDroppable && !!dropState && (dropState.isDropTarget({type: 'root'}) ?? false); @@ -854,26 +888,29 @@ export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', - + + {props.renderEmptyState(renderValues)} - - + + ); } let {rowGroupProps} = useTableRowGroup(); + let TBody = useElementType('tbody'); return ( - {isDroppable && } - + {emptyState} - + ); }); @@ -913,10 +950,12 @@ export const Row = /*#__PURE__*/ createBranchComponent( let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); + let {isVirtualized, CollectionBranch} = useContext(CollectionRendererContext); let {rowProps, ...states} = useTableRow( { node: item, - shouldSelectOnPressUp: !!dragState + shouldSelectOnPressUp: !!dragState, + isVirtualized }, state, ref @@ -981,19 +1020,22 @@ export const Row = /*#__PURE__*/ createBranchComponent( } }); + let TR = useElementType('tr'); + let TD = useElementType('td'); + return ( <> {dragAndDropHooks?.useDropIndicator && renderDropIndicator({type: 'item', key: item.key, dropPosition: 'before'}) } {dropIndicator && !dropIndicator.isHidden && ( - - + +
- - + + )} - - + - + {dragAndDropHooks?.useDropIndicator && state.collection.getKeyAfter(item.key) == null && renderDropIndicator({type: 'item', key: item.key, dropPosition: 'after'}) } @@ -1045,6 +1087,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( idScope: props.id }); + // eslint-disable-next-line react-hooks/exhaustive-deps let ctx = useMemo(() => ({idScope: props.id, dependencies}), [props.id, ...dependencies]); return ( @@ -1092,13 +1135,15 @@ export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, let ref = useObjectRef(forwardedRef); let state = useContext(TableStateContext)!; let {dragState} = useContext(DragAndDropContext); + let {isVirtualized} = useContext(CollectionRendererContext); // @ts-ignore cell.column = state.collection.columns[cell.index]; let {gridCellProps, isPressed} = useTableCell({ node: cell, - shouldSelectOnPressUp: !!dragState + shouldSelectOnPressUp: !!dragState, + isVirtualized }, state, ref); let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let {hoverProps, isHovered} = useHover({}); @@ -1115,8 +1160,10 @@ export const Cell = /*#__PURE__*/ createLeafComponent('cell', (props: CellProps, } }); + let TD = useElementType('td'); + return ( - {renderProps.children} - + ); }); @@ -1171,21 +1218,24 @@ function TableDropIndicator(props: TableDropIndicatorProps, ref: ForwardedRef} data-drop-target={isDropTarget || undefined}> -
{renderProps.children} - - + + ); } @@ -1200,22 +1250,24 @@ function RootDropIndicator() { }, dropState!, ref); let isDropTarget = dropState!.isDropTarget({type: 'root'}); let {visuallyHiddenProps} = useVisuallyHidden(); + let TR = useElementType('tr'); + let TD = useElementType('td'); if (!isDropTarget && dropIndicatorProps['aria-hidden']) { return null; } return ( - -
- - + + ); } diff --git a/packages/react-aria-components/src/TableLayout.ts b/packages/react-aria-components/src/TableLayout.ts new file mode 100644 index 00000000000..571a7b38976 --- /dev/null +++ b/packages/react-aria-components/src/TableLayout.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {TableLayout as BaseTableLayout, TableLayoutProps} from '@react-stately/layout'; +import {LayoutOptionsDelegate} from './Virtualizer'; +import {TableColumnResizeStateContext} from './Table'; +import {useContext, useMemo} from 'react'; + +export class TableLayout extends BaseTableLayout implements LayoutOptionsDelegate { + // Invalidate the layout whenever the column widths change. + useLayoutOptions() { + // This is not a React class component, just a regular class. + /* eslint-disable react-hooks/rules-of-hooks */ + let colResizeState = useContext(TableColumnResizeStateContext); + return useMemo(() => ({ + columnWidths: colResizeState?.columnWidths + }), [colResizeState?.columnWidths]); + } +} diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index 85629cf716b..dd642e63574 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -13,7 +13,7 @@ import {AriaLabelingProps, Key, LinkDOMProps} from '@react-types/shared'; import {AriaTabListProps, AriaTabPanelProps, mergeProps, Orientation, useFocusRing, useHover, useTab, useTabList, useTabPanel} from 'react-aria'; import {Collection, Node, TabListState, useTabListState} from 'react-stately'; -import {CollectionChildren, CollectionDocumentContext, CollectionPortal, CollectionProps, createLeafComponent, useCollectionDocument} from './Collection'; +import {CollectionDocumentContext, CollectionPortal, CollectionProps, CollectionRendererContext, createLeafComponent, useCollectionDocument} from './Collection'; import {ContextValue, createHideableComponent, forwardRefType, Hidden, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, useObjectRef} from '@react-aria/utils'; import React, {createContext, ForwardedRef, forwardRef, JSX, RefObject, useContext, useMemo} from 'react'; @@ -208,6 +208,7 @@ interface TabListInnerProps { function TabListInner({props, forwardedRef: ref}: TabListInnerProps) { let state = useContext(TabListStateContext)!; + let {CollectionRoot} = useContext(CollectionRendererContext); let {orientation = 'horizontal', keyboardActivation = 'automatic'} = useSlottedContext(TabsContext)!; let objectRef = useObjectRef(ref); @@ -237,7 +238,7 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ref={objectRef} {...renderProps} data-orientation={orientation || undefined}> - +
); } diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index 30878f78d89..c388c9b9f10 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -12,7 +12,7 @@ import {AriaTagGroupProps, useFocusRing, useHover, useTag, useTagGroup} from 'react-aria'; import {ButtonContext} from './Button'; -import {CollectionChildren, CollectionDocumentContext, CollectionProps, createLeafComponent, ItemRenderProps, useCollectionDocument, useCollectionPortal} from './Collection'; +import {CollectionDocumentContext, CollectionProps, CollectionRendererContext, createLeafComponent, ItemRenderProps, useCollectionDocument, useCollectionPortal} from './Collection'; import {ContextValue, DOMProps, forwardRefType, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {filterDOMProps, mergeProps, useObjectRef} from '@react-aria/utils'; import {HoverEvents, Key, LinkDOMProps} from '@react-types/shared'; @@ -129,6 +129,7 @@ interface TagListInnerProps { function TagListInner({props, forwardedRef}: TagListInnerProps) { let state = useContext(ListStateContext)!; + let {CollectionRoot} = useContext(CollectionRendererContext); let [gridProps, ref] = useContextProps(props, forwardedRef, TagListContext); delete gridProps.items; delete gridProps.renderEmptyState; @@ -157,7 +158,7 @@ function TagListInner({props, forwardedRef}: TagListInnerProps data-focus-visible={isFocusVisible || undefined}> {state.collection.size === 0 && props.renderEmptyState ? props.renderEmptyState(renderValues) - : } + : }
); } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 854a16f38b2..ad91b178853 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -11,7 +11,7 @@ */ import {AriaTreeGridListProps, useTreeGridList, useTreeGridListItem} from '@react-aria/tree'; -import {BaseCollection, CollectionChildren, CollectionProps, createBranchComponent, createLeafComponent, ItemRenderProps, NodeValue, useCachedChildren, useCollection} from './Collection'; +import {BaseCollection, CollectionProps, CollectionRendererContext, createBranchComponent, createLeafComponent, ItemRenderProps, NodeValue, useCachedChildren, useCollection} from './Collection'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {ContextValue, DEFAULT_SLOT, forwardRefType, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; @@ -160,6 +160,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne onExpandedChange, disabledBehavior = 'selection' } = props; + let {CollectionRoot, isVirtualized, layoutDelegate} = useContext(CollectionRendererContext); // Kinda annoying that we have to replicate this code here as well as in useTreeState, but don't want to add // flattenCollection stuff to useTreeState. Think about this later @@ -183,7 +184,11 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne disabledBehavior }); - let {gridProps} = useTreeGridList(props, state, ref); + let {gridProps} = useTreeGridList({ + ...props, + isVirtualized, + layoutDelegate + }, state, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let renderValues = { @@ -237,7 +242,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne values={[ [UNSTABLE_TreeStateContext, state] ]}> - + {emptyState}
diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx new file mode 100644 index 00000000000..80992480910 --- /dev/null +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CollectionRenderer, CollectionRendererContext} from './Collection'; +import {DropTargetDelegate, Node} from '@react-types/shared'; +import {Layout, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; +import React, {ReactNode, useContext, useMemo} from 'react'; +import {useScrollView, VirtualizerContext, VirtualizerItem} from '@react-aria/virtualizer'; + +export interface LayoutOptionsDelegate { + useLayoutOptions?(): O +} + +interface ILayout extends Layout, O>, Partial, LayoutOptionsDelegate {} + +export interface VirtualizerProps { + children: ReactNode, + layout: ILayout +} + +export function Virtualizer(props: VirtualizerProps) { + let {children, layout} = props; + let renderer: CollectionRenderer = useMemo(() => ({ + isVirtualized: true, + layoutDelegate: layout, + dropTargetDelegate: layout.getDropTargetFromPoint ? layout as DropTargetDelegate : undefined, + CollectionRoot({collection, focusedKey, scrollRef}) { + let layoutOptions = layout.useLayoutOptions?.(); + let state = useVirtualizerState({ + layout, + collection, + renderView: (type, item) => { + return item?.render?.(item); + }, + renderWrapper, + onVisibleRectChange(rect) { + scrollRef!.current!.scrollLeft = rect.x; + scrollRef!.current!.scrollTop = rect.y; + }, + persistedKeys: useMemo(() => focusedKey != null ? new Set([focusedKey]) : new Set(), [focusedKey]), + layoutOptions + }); + + let {contentProps} = useScrollView({ + onVisibleRectChange: state.setVisibleRect, + contentSize: state.contentSize, + onScrollStart: state.startScrolling, + onScrollEnd: state.endScrolling + }, scrollRef!); + + if (state.visibleViews.length === 0) { + return null; + } + + return ( +
+ + {state.visibleViews} + +
+ ); + }, + CollectionBranch({parent}) { + let virtualizer = useContext(VirtualizerContext); + return virtualizer!.virtualizer.getChildren(parent.key); + } + }), [layout]); + + return ( + + {children} + + ); +} + +function renderWrapper( + parent: ReusableView | null, + reusableView: ReusableView +) { + return ( + + {reusableView.rendered} + + ); +} diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 363e7e0dbaf..54f6a0bf9f3 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -61,6 +61,7 @@ export {Separator, SeparatorContext} from './Separator'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from './Slider'; export {Switch, SwitchContext} from './Switch'; export {Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from './Table'; +export {TableLayout} from './TableLayout'; export {Tabs, TabList, TabPanel, Tab, TabsContext, TabListStateContext} from './Tabs'; export {TagGroup, TagGroupContext, TagList, TagListContext, Tag} from './TagGroup'; export {Text, TextContext} from './Text'; @@ -71,9 +72,11 @@ export {Toolbar, ToolbarContext} from './Toolbar'; export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip'; export {UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeContext, UNSTABLE_TreeItemContent, UNSTABLE_TreeStateContext} from './Tree'; export {useDragAndDrop, DropIndicator, DropIndicatorContext, DragAndDropContext} from './useDragAndDrop'; +export {Virtualizer} from './Virtualizer'; export {DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem, SSRProvider, RouterProvider, I18nProvider, useLocale} from 'react-aria'; export {FormValidationContext} from 'react-stately'; export {parseColor, getColorChannels} from '@react-stately/color'; +export {ListLayout} from '@react-stately/layout'; export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from './Breadcrumbs'; export type {ButtonProps, ButtonRenderProps} from './Button'; @@ -129,9 +132,11 @@ export type {TooltipProps, TooltipRenderProps, TooltipTriggerComponentProps} fro export type {TreeProps, TreeRenderProps, TreeItemProps, TreeItemRenderProps, TreeItemContentProps, TreeItemContentRenderProps} from './Tree'; export type {DragAndDropHooks, DragAndDropOptions, DropIndicatorProps} from './useDragAndDrop'; export type {ContextValue, SlotProps} from './utils'; +export type {VirtualizerProps} from './Virtualizer'; export type {DateValue, DateRange, TimeValue} from 'react-aria'; export type {DirectoryDropItem, DraggableCollectionEndEvent, DraggableCollectionMoveEvent, DraggableCollectionStartEvent, DragPreviewRenderer, DragTypes, DropItem, DropOperation, DroppableCollectionDropEvent, DroppableCollectionEnterEvent, DroppableCollectionExitEvent, DroppableCollectionInsertDropEvent, DroppableCollectionMoveEvent, DroppableCollectionOnItemDropEvent, DroppableCollectionReorderEvent, DroppableCollectionRootDropEvent, DropPosition, DropTarget, FileDropItem, ItemDropTarget, RootDropTarget, TextDropItem, PressEvent} from 'react-aria'; 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'; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 5beb8d58c1d..0ef5242378d 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -10,9 +10,11 @@ * governing permissions and limitations under the License. */ -import {Button, GridList, GridListItem, GridListItemProps} from 'react-aria-components'; +import {Button, GridList, GridListItem, GridListItemProps, ListLayout, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; -import React from 'react'; +import {GridLayout} from '@react-spectrum/card'; +import React, {useMemo} from 'react'; +import {Size} from '@react-stately/virtualizer'; import styles from '../example/index.css'; export default { @@ -71,3 +73,45 @@ GridListExample.story = { } } }; + +export function VirtualizedGridList() { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i, name: `Item ${i}`}); + } + + let layout = useMemo(() => { + return new ListLayout({ + rowHeight: 25 + }); + }, []); + + return ( + + + {item => {item.name}} + + + ); +} + +export function VirtualizedGridListGrid() { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i, name: `Item ${i}`}); + } + + let layout = useMemo(() => { + return new GridLayout({ + minItemSize: new Size(40, 40) + }); + }, []); + + return ( + + + {item => {item.name}} + + + ); +} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index ba4281f66ab..54e39f686b0 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -11,14 +11,14 @@ */ import {action} from '@storybook/addon-actions'; -import {Collection, CollectionRenderer, CollectionRendererContext} from '../src/Collection'; -import {Header, ListBox, ListBoxItem, ListBoxProps, Section, Separator, Text, useDragAndDrop} from 'react-aria-components'; -import {ListLayout} from '@react-stately/layout'; +import {Collection} from '../src/Collection'; +import {GridLayout} from '@react-spectrum/card'; +import {Header, ListBox, ListBoxItem, ListBoxProps, ListLayout, Section, Separator, Text, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {MyListBoxItem} from './utils'; -import React, {useContext, useMemo} from 'react'; +import React, {useMemo} from 'react'; +import {Size} from '@react-stately/virtualizer'; import styles from '../example/index.css'; import {useListData} from 'react-stately'; -import {Virtualizer, VirtualizerContext} from '@react-aria/virtualizer'; export default { title: 'React Aria Components' @@ -238,8 +238,16 @@ export function VirtualizedListBox() { } sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); } + + let layout = useMemo(() => { + return new ListLayout({ + rowHeight: 25, + estimatedHeadingHeight: 26 + }); + }, []); + return ( - + {section => (
@@ -250,52 +258,44 @@ export function VirtualizedListBox() {
)}
-
+ ); } -function VirtualizedCollection({children}) { +export function VirtualizedListBoxEmpty() { + let layout = useMemo(() => { + return new ListLayout({ + rowHeight: 25, + estimatedHeadingHeight: 26 + }); + }, []); + return ( - - {children} - + + 'Empty'}> + <> + + ); } -const VirtualizedCollectionRenderer: CollectionRenderer = (collection, parent) => { - if (parent) { - // eslint-disable-next-line react-hooks/rules-of-hooks - let virtualizer = useContext(VirtualizerContext)!; - return virtualizer.virtualizer.getChildren(parent.key); +export function VirtualizedListBoxGrid() { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i, name: `Item ${i}`}); } - // eslint-disable-next-line react-hooks/rules-of-hooks let layout = useMemo(() => { - return new ListLayout({ - estimatedRowHeight: 32, - estimatedHeadingHeight: 26, - padding: 4, - loaderHeight: 40, - placeholderHeight: 32 - // collator + return new GridLayout({ + minItemSize: new Size(40, 40) }); }, []); return ( - - {(type, item) => { - switch (type) { - case 'placeholder': - return null; - default: - return item.render!(item); - } - }} + + + {item => {item.name}} + ); -}; +} diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 5fd01c0c10b..739749126b1 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Button, Label, ListBox, OverlayArrow, Popover, Select, SelectValue} from 'react-aria-components'; +import {Button, Label, ListBox, ListLayout, OverlayArrow, Popover, Select, SelectValue, Virtualizer} from 'react-aria-components'; import {MyListBoxItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; @@ -81,3 +81,23 @@ export const SelectManyItems = () => ( ); + +export const VirtualizedSelect = () => ( + +); diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index cfb9d21c6bf..34dbb6b569f 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -11,10 +11,10 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Cell, Checkbox, CheckboxProps, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, useDragAndDrop} from 'react-aria-components'; +import {Button, Cell, Checkbox, CheckboxProps, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, Heading, Menu, MenuTrigger, Modal, ModalOverlay, Popover, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {isTextDropItem} from 'react-aria'; import {MyMenuItem} from './utils'; -import React from 'react'; +import React, {useMemo} from 'react'; import styles from '../example/index.css'; import {useListData} from 'react-stately'; @@ -392,3 +392,75 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => { ); }; + +export function VirtualizedTable() { + let items: {id: number, foo: string, bar: string, baz: string}[] = []; + for (let i = 0; i < 1000; i++) { + items.push({id: i, foo: `Foo ${i}`, bar: `Bar ${i}`, baz: `Baz ${i}`}); + } + + let layout = useMemo(() => { + return new TableLayout({ + rowHeight: 25, + headingHeight: 25 + }); + }, []); + + return ( + + + + Foo + Bar + Baz + + + {item => ( + + {item.foo} + {item.bar} + {item.baz} + + )} + +
+
+ ); +} + +export function VirtualizedTableWithResizing() { + let items: {id: number, foo: string, bar: string, baz: string}[] = []; + for (let i = 0; i < 1000; i++) { + items.push({id: i, foo: `Foo ${i}`, bar: `Bar ${i}`, baz: `Baz ${i}`}); + } + + let layout = useMemo(() => { + return new TableLayout({ + rowHeight: 25, + headingHeight: 25 + }); + }, []); + + return ( + + + + + Foo + Bar + Baz + + + {item => ( + + {item.foo} + {item.bar} + {item.baz} + + )} + +
+
+
+ ); +} diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 8c2b415e0d4..285aae433da 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -11,10 +11,10 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Checkbox, CheckboxProps, Collection, Menu, MenuTrigger, Popover, Text, TreeItemProps, TreeProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent} from 'react-aria-components'; +import {Button, Checkbox, CheckboxProps, Collection, ListLayout, Menu, MenuTrigger, Popover, Text, TreeItemProps, TreeProps, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import {MyMenuItem} from './utils'; -import React, {ReactNode} from 'react'; +import React, {ReactNode, useMemo} from 'react'; import styles from '../example/index.css'; export default { @@ -313,3 +313,19 @@ export const EmptyTree = (args: TreeProps) => ( )} ); + +export function VirtualizedTree(args) { + let layout = useMemo(() => { + return new ListLayout({ + rowHeight: 30 + }); + }, []); + + return ( + + + + ); +} + +VirtualizedTree.story = TreeExampleDynamic.story; diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index e1fb8158249..0478a357f14 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -62,6 +62,11 @@ describe('ListBox', () => { jest.useFakeTimers(); }); + beforeEach(() => { + jest.spyOn(HTMLElement.prototype, 'scrollLeft', 'get').mockImplementation(() => 0); + jest.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(() => 0); + }); + afterEach(() => { act(() => {jest.runAllTimers();}); jest.restoreAllMocks(); From 1902224c857d65d554e524e6eccf3e3329c18dba Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 10 Jun 2024 18:10:15 -0700 Subject: [PATCH 04/14] Fix ScrollView overflow --- packages/@react-aria/virtualizer/src/ScrollView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 36372f3ea3d..40fe85de4e5 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -227,8 +227,8 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject Date: Mon, 10 Jun 2024 18:10:32 -0700 Subject: [PATCH 05/14] Fix layout logic error --- packages/@react-stately/layout/src/TableLayout.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 11080eb5b5f..97ad53800d8 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -61,9 +61,11 @@ export class TableLayout extends ListLayout { // If columnWidths were provided via layoutOptions, update those. // Otherwise, calculate column widths ourselves. - if (invalidationContext.layoutOptions?.columnWidths && invalidationContext.layoutOptions.columnWidths !== this.columnWidths) { - this.columnWidths = invalidationContext.layoutOptions.columnWidths; - invalidationContext.sizeChanged = true; + if (invalidationContext.layoutOptions?.columnWidths) { + if (invalidationContext.layoutOptions.columnWidths !== this.columnWidths) { + this.columnWidths = invalidationContext.layoutOptions.columnWidths; + invalidationContext.sizeChanged = true; + } } else if (invalidationContext.sizeChanged || this.columnsChanged(newCollection, this.collection)) { let columnLayout = new TableColumnLayout({}); this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer.visibleRect.width, newCollection, new Map()); From 7357a4d60fbf5aaaa1f5bca2928b4c37713048e8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 10 Jun 2024 18:21:34 -0700 Subject: [PATCH 06/14] add scroll-padding-top to table examples --- packages/react-aria-components/stories/Table.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 34dbb6b569f..f671a97c1dc 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -408,7 +408,7 @@ export function VirtualizedTable() { return ( - +
Foo Bar @@ -442,7 +442,7 @@ export function VirtualizedTableWithResizing() { }, []); return ( - +
From 452dd323248883fd9335310de5b3b1550c2bca54 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 14 Jun 2024 15:44:34 -0700 Subject: [PATCH 07/14] Add some tests --- .../react-aria-components/src/GridList.tsx | 4 +- .../test/GridList.test.js | 47 +++++++++++++- .../test/ListBox.test.js | 51 +++++++++++++++- .../react-aria-components/test/Table.test.js | 61 ++++++++++++++++++- .../react-aria-components/test/Tree.test.tsx | 36 ++++++++++- 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 5ba6ac3beed..148f5fd3c78 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -269,10 +269,12 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); + let {isVirtualized} = useContext(CollectionRendererContext); let {rowProps, gridCellProps, descriptionProps, ...states} = useGridListItem( { node: item, - shouldSelectOnPressUp: !!dragState + shouldSelectOnPressUp: !!dragState, + isVirtualized }, state, ref diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 606f3d618c0..f1308c38bf2 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -11,7 +11,7 @@ */ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; -import {Button, Checkbox, DropIndicator, GridList, GridListContext, GridListItem, useDragAndDrop} from '../'; +import {Button, Checkbox, DropIndicator, GridList, GridListContext, GridListItem, ListLayout, useDragAndDrop, Virtualizer} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -49,6 +49,7 @@ describe('GridList', () => { afterEach(() => { act(() => {jest.runAllTimers();}); + jest.clearAllMocks(); }); it('should render with default classes', () => { @@ -325,6 +326,50 @@ describe('GridList', () => { expect(document.activeElement).toBe(document.body); }); + it('should support virtualizer', async () => { + let layout = new ListLayout({ + rowHeight: 25 + }); + + let items = []; + for (let i = 0; i < 50; i++) { + items.push({id: i, name: 'Item ' + i}); + } + + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let {getByRole, getAllByRole} = render( + + + {item => {item.name}} + + + ); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(7); + expect(rows.map(r => r.textContent)).toEqual(['Item 0', 'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6']); + for (let row of rows) { + expect(row).toHaveAttribute('aria-rowindex'); + } + + let grid = getByRole('grid'); + grid.scrollTop = 200; + fireEvent.scroll(grid); + + rows = getAllByRole('row'); + expect(rows).toHaveLength(8); + expect(rows.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14']); + + await user.tab(); + await user.keyboard('{End}'); + + rows = getAllByRole('row'); + expect(rows).toHaveLength(9); + expect(rows.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 49']); + }); + describe('drag and drop', () => { it('should support drag button slot', () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 0478a357f14..3ec3c5811c7 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -18,10 +18,11 @@ import { Header, Heading, ListBox, ListBoxContext, - ListBoxItem, Modal, + ListBoxItem, ListLayout, Modal, Section, Text, - useDragAndDrop + useDragAndDrop, + Virtualizer } from '../'; import React, {useState} from 'react'; import userEvent from '@testing-library/user-event'; @@ -689,6 +690,52 @@ describe('ListBox', () => { expect(onScroll).toHaveBeenCalled(); }); + it('should support virtualizer', async () => { + let layout = new ListLayout({ + rowHeight: 25 + }); + + let items = []; + for (let i = 0; i < 50; i++) { + items.push({id: i, name: 'Item ' + i}); + } + + jest.restoreAllMocks(); // don't mock scrollTop for this test + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let {getByRole, getAllByRole} = render( + + + {item => {item.name}} + + + ); + + let options = getAllByRole('option'); + expect(options).toHaveLength(7); + expect(options.map(r => r.textContent)).toEqual(['Item 0', 'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6']); + for (let option of options) { + expect(option).toHaveAttribute('aria-setsize', '50'); + expect(option).toHaveAttribute('aria-posinset'); + } + + let listbox = getByRole('listbox'); + listbox.scrollTop = 200; + fireEvent.scroll(listbox); + + options = getAllByRole('option'); + expect(options).toHaveLength(8); + expect(options.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14']); + + await user.tab(); + await user.keyboard('{End}'); + + options = getAllByRole('option'); + expect(options).toHaveLength(9); + expect(options.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 49']); + }); + describe('drag and drop', () => { it('should support draggable items', () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index a7682454a59..2214c622f7a 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -11,7 +11,7 @@ */ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; -import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, DropIndicator, ResizableTableContainer, Row, Table, TableBody, TableHeader, useDragAndDrop, useTableOptions} from '../'; +import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, DropIndicator, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, useTableOptions, Virtualizer} from '../'; import React, {useMemo, useState} from 'react'; import {resizingTests} from '@react-aria/table/test/tableResizingTests'; import {setInteractionModality} from '@react-aria/interactions'; @@ -176,6 +176,10 @@ describe('Table', () => { jest.useFakeTimers(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render with default classes', () => { let {getByRole, getAllByRole} = renderTable(); let table = getByRole('grid'); @@ -788,6 +792,61 @@ describe('Table', () => { expect(items[1]).not.toHaveAttribute('data-focus-visible-within', 'true'); }); + it('should support virtualizer', async () => { + let layout = new TableLayout({ + rowHeight: 25 + }); + + let items = []; + for (let i = 0; i < 50; i++) { + items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); + } + + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let {getByRole, getAllByRole} = render( + +
+ + Foo + Bar + + + {item => ( + + {item.foo} + {item.bar} + + )} + +
+
+ ); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(8); + expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 0Bar 0', 'Foo 1Bar 1', 'Foo 2Bar 2', 'Foo 3Bar 3', 'Foo 4Bar 4', 'Foo 5Bar 5', 'Foo 6Bar 6']); + for (let row of rows) { + expect(row).toHaveAttribute('aria-rowindex'); + } + + let grid = getByRole('grid'); + grid.scrollTop = 200; + fireEvent.scroll(grid); + + 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']); + + 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']); + }); + describe('drag and drop', () => { it('should support drag button slot', () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 151f3cc414a..f2ee3042e61 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -11,7 +11,7 @@ */ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; -import {Button, Checkbox, Collection, Text, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent} from '../'; +import {Button, Checkbox, Collection, ListLayout, Text, UNSTABLE_Tree, UNSTABLE_TreeItem, UNSTABLE_TreeItemContent, Virtualizer} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -385,6 +385,40 @@ describe('Tree', () => { expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['projects'])); }); + it('should support virtualizer', async () => { + let layout = new ListLayout({ + rowHeight: 25 + }); + + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let {getByRole, getAllByRole} = render( + + + + ); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(7); + expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Projects', 'Project 1', 'Project 2', 'Project 2A', 'Project 2B', 'Project 2C', 'Project 3']); + + let tree = getByRole('treegrid'); + tree.scrollTop = 200; + fireEvent.scroll(tree); + + rows = getAllByRole('row'); + expect(rows).toHaveLength(8); + expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A']); + + await user.tab(); + await user.keyboard('{End}'); + + rows = getAllByRole('row'); + expect(rows).toHaveLength(9); + expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A', 'Reports 2']); + }); + describe('general interactions', () => { it('should support hover on rows', async () => { let onHoverStart = jest.fn(); From 9278b08d00971fc22223cdf9876f79e901a24398 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 14 Jun 2024 16:19:52 -0700 Subject: [PATCH 08/14] Fix table empty state --- .../@react-stately/layout/src/TableLayout.ts | 22 +++++++++------- .../stories/Table.stories.tsx | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 97ad53800d8..537aa9318eb 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -266,15 +266,19 @@ export class TableLayout extends ListLayout { children.push({layoutInfo: loader, validRect: loader.rect}); y = loader.rect.maxY; width = Math.max(width, rect.width); - } else if (children.length === 0 && 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 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; + } } rect.width = width; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index f671a97c1dc..32180002ebc 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -464,3 +464,29 @@ export function VirtualizedTableWithResizing() { ); } + +export function VirtualizedTableWithEmptyState() { + let layout = useMemo(() => { + return new TableLayout({ + rowHeight: 25, + headingHeight: 25 + }); + }, []); + + return ( + + + + + Foo + Bar + Baz + + 'Empty'}> + <> + +
+
+
+ ); +} From 35c9fc709643f0fe8a8fcc306d65c16bfdcfe14b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 14 Jun 2024 16:20:11 -0700 Subject: [PATCH 09/14] lint --- packages/react-aria-components/test/Tree.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index f2ee3042e61..264d80a7028 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -401,7 +401,7 @@ describe('Tree', () => { let rows = getAllByRole('row'); expect(rows).toHaveLength(7); - expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Projects', 'Project 1', 'Project 2', 'Project 2A', 'Project 2B', 'Project 2C', 'Project 3']); + expect(rows.map(r => r.querySelector('span')!.textContent)).toEqual(['Projects', 'Project 1', 'Project 2', 'Project 2A', 'Project 2B', 'Project 2C', 'Project 3']); let tree = getByRole('treegrid'); tree.scrollTop = 200; @@ -409,14 +409,14 @@ describe('Tree', () => { rows = getAllByRole('row'); expect(rows).toHaveLength(8); - expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A']); + expect(rows.map(r => r.querySelector('span')!.textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A']); await user.tab(); await user.keyboard('{End}'); rows = getAllByRole('row'); expect(rows).toHaveLength(9); - expect(rows.map(r => r.querySelector('span').textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A', 'Reports 2']); + expect(rows.map(r => r.querySelector('span')!.textContent)).toEqual(['Project 4', 'Project 5', 'Project 5A', 'Project 5B', 'Project 5C', 'Reports', 'Reports 1', 'Reports 1A', 'Reports 2']); }); describe('general interactions', () => { From a7fb8036a032800b1592b30e8b52885beeb374b2 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 14 Jun 2024 18:16:55 -0700 Subject: [PATCH 10/14] Fix scrollview outside of React 18 strict mode --- .../virtualizer/src/ScrollView.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 40fe85de4e5..11138ad91de 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -183,12 +183,24 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - // React doesn't allow flushSync inside effects so pass an identity function instead. - // This only happens on initial render. The resize observer will also call updateSize - // once it initializes, but we need earlier initialization in a layout effect to avoid - // a flash of missing content. - updateSize(fn => fn()); + // React doesn't allow flushSync inside effects, so queue a microtask. + // We also need to wait until all refs are set (e.g. when passing a ref down from a parent). + queueMicrotask(() => { + if (!didUpdateSize.current) { + didUpdateSize.current = true; + updateSize(flushSync); + } + }); + }, [updateSize]); + useEffect(() => { + if (!didUpdateSize.current) { + // If useEffect ran before the above microtask, we are in a synchronous render (e.g. act). + // Update the size here so that you don't need to mock timers in tests. + didUpdateSize.current = true; + updateSize(fn => fn()); + } }, [updateSize]); let onResize = useCallback(() => { updateSize(flushSync); From 7683a62a5b824874db054c8932b7e4546df2069b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 17 Jun 2024 12:22:38 -0700 Subject: [PATCH 11/14] Use content size instead of number of visible views to skip rendering Fixes virtualized select --- packages/react-aria-components/src/Virtualizer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 80992480910..0e0a1e35b79 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -57,7 +57,7 @@ export function Virtualizer(props: VirtualizerProps) { onScrollEnd: state.endScrolling }, scrollRef!); - if (state.visibleViews.length === 0) { + if (state.contentSize.area === 0) { return null; } From 714f3d325f5f68211b882b56836f9807a9503e74 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 18 Jun 2024 10:56:48 -0700 Subject: [PATCH 12/14] Make ListDropTargetDelegate take an Iterable instead of a Collection RAC TableCollection now lists the head and body as its root nodes rather than the rows. --- packages/@react-aria/dnd/src/ListDropTargetDelegate.ts | 8 ++++---- packages/react-aria-components/src/Table.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts index ff46a79ac46..262bdffa708 100644 --- a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts +++ b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts @@ -1,4 +1,4 @@ -import {Collection, Direction, DropTarget, DropTargetDelegate, Node, Orientation} from '@react-types/shared'; +import {Direction, DropTarget, DropTargetDelegate, Node, Orientation} from '@react-types/shared'; import {RefObject} from 'react'; interface ListDropTargetDelegateOptions { @@ -30,13 +30,13 @@ interface ListDropTargetDelegateOptions { // direction. For grids, it is the secondary direction. export class ListDropTargetDelegate implements DropTargetDelegate { - private collection: Collection>; + private collection: Iterable>; private ref: RefObject; private layout: 'stack' | 'grid'; private orientation: Orientation; private direction: Direction; - constructor(collection: Collection>, ref: RefObject, options?: ListDropTargetDelegateOptions) { + constructor(collection: Iterable>, ref: RefObject, options?: ListDropTargetDelegateOptions) { this.collection = collection; this.ref = ref; this.layout = options?.layout || 'stack'; @@ -73,7 +73,7 @@ export class ListDropTargetDelegate implements DropTargetDelegate { } getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { - if (this.collection.size === 0) { + if (this.collection[Symbol.iterator]().next().done) { return {type: 'root'}; } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index b22770eb252..8c2a39fd936 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -383,7 +383,7 @@ function Table(props: TableProps, ref: ForwardedRef) { ref, layoutDelegate }); - let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref); + let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection.rows, ref); droppableCollection = dragAndDropHooks.useDroppableCollection!({ keyboardDelegate, dropTargetDelegate From 79e26e66da6c45468829c0c5800a517bc5b1a6b6 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 18 Jun 2024 14:38:57 -0700 Subject: [PATCH 13/14] fix typo --- packages/@react-spectrum/card/src/BaseLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/card/src/BaseLayout.tsx b/packages/@react-spectrum/card/src/BaseLayout.tsx index 7062c185898..a2bc1a0c11b 100644 --- a/packages/@react-spectrum/card/src/BaseLayout.tsx +++ b/packages/@react-spectrum/card/src/BaseLayout.tsx @@ -56,7 +56,7 @@ export class BaseLayout extends Layout, CardViewLayoutOptions> implem validate(invalidationContext: InvalidationContext) { this.collection = this.virtualizer.collection as GridCollection; this.isLoading = invalidationContext.layoutOptions?.isLoading || false; - this.direction = invalidationContext.layoutOptions?.direction || 'rtl'; + this.direction = invalidationContext.layoutOptions?.direction || 'ltr'; this.buildCollection(invalidationContext); // Remove layout info that doesn't exist in new collection From 1b019ffc432e92d7dc2fa9c61e834975251b0f4c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 19 Jun 2024 19:56:25 -0700 Subject: [PATCH 14/14] Fix tsc-strict --- packages/@react-aria/virtualizer/src/ScrollView.tsx | 2 +- packages/react-aria-components/src/Collection.tsx | 2 +- packages/react-aria-components/src/Table.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 22c332783f3..29b902a6d4e 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -55,7 +55,7 @@ function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { +export function useScrollView(props: ScrollViewProps, ref: RefObject) { let { contentSize, onVisibleRectChange, diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index a9d7c759737..9ab8098f883 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -965,7 +965,7 @@ export interface CollectionBranchProps { export interface CollectionRootProps extends HTMLAttributes { collection: ICollection>, focusedKey?: Key | null, - scrollRef?: RefObject + scrollRef?: RefObject } export interface CollectionRenderer { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index acf711b4845..aa5ea5b07f9 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -183,8 +183,8 @@ class TableCollection extends BaseCollection implements ITableCollection, - scrollRef: RefObject, + tableRef: RefObject, + scrollRef: RefObject, // Dependency inject useTableColumnResizeState so it doesn't affect bundle size unless you're using ResizableTableContainer. useTableColumnResizeState: typeof useTableColumnResizeState, onResizeStart?: (widths: Map) => void,