From c4ca76d762273e3abe8a1d962b765fb2b69d88a8 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 7 Mar 2025 16:36:06 -0800 Subject: [PATCH 01/90] initial support for async loading in Combobox/picker/listbox in RAC --- packages/@react-aria/utils/src/useLoadMore.ts | 62 +++++----- .../react-aria-components/src/ComboBox.tsx | 29 +++-- .../react-aria-components/src/ListBox.tsx | 60 +++++++++- packages/react-aria-components/src/Select.tsx | 30 +++-- .../stories/ComboBox.stories.tsx | 103 ++++++++++++++++- .../stories/Select.stories.tsx | 108 +++++++++++++++++- 6 files changed, 344 insertions(+), 48 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 9b3b9bdad14..7704a60ad67 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -10,15 +10,15 @@ * governing permissions and limitations under the License. */ +import {Collection, Node} from '@react-types/shared'; import {RefObject, useCallback, useRef} from 'react'; import {useEvent} from './useEvent'; - import {useLayoutEffect} from './useLayoutEffect'; export interface LoadMoreProps { /** Whether data is currently being loaded. */ isLoading?: boolean, - /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ onLoadMore?: () => void, /** * The amount of offset from the bottom of your scrollable region that should trigger load more. @@ -28,52 +28,58 @@ export interface LoadMoreProps { * @default 1 */ scrollOffset?: number, - /** The data currently loaded. */ - items?: any + // TODO: will need to refactor the existing components that use items + // /** The data currently loaded. */ + // items?: any, + collection?: Collection> } export function useLoadMore(props: LoadMoreProps, ref: RefObject) { - let {isLoading, onLoadMore, scrollOffset = 1, items} = props; + // let {isLoading, onLoadMore, scrollOffset = 1, items} = props; + let {isLoading, onLoadMore, scrollOffset = 1, collection} = props; // Handle scrolling, and call onLoadMore when nearing the bottom. - let isLoadingRef = useRef(isLoading); - let prevProps = useRef(props); let onScroll = useCallback(() => { - if (ref.current && !isLoadingRef.current && onLoadMore) { + if (ref.current && !isLoading && onLoadMore) { let shouldLoadMore = ref.current.scrollHeight - ref.current.scrollTop - ref.current.clientHeight < ref.current.clientHeight * scrollOffset; if (shouldLoadMore) { - isLoadingRef.current = true; onLoadMore(); } } - }, [onLoadMore, ref, scrollOffset]); + }, [onLoadMore, isLoading, ref, scrollOffset]); - let lastItems = useRef(items); + let lastCollection = useRef(collection); + // If we are in a loading state when this hook is called, we can assume that the collection will update in the future so we don't + // want to trigger another loadMore until the collection has updated as a result of the load. + // TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking + // this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update + let collectionAwaitingUpdate = useRef(isLoading); useLayoutEffect(() => { - // Only update isLoadingRef if props object actually changed, - // not if a local state change occurred. - if (props !== prevProps.current) { - isLoadingRef.current = isLoading; - prevProps.current = props; + // Only update this flag if the collection changes when we aren't loading. Guard against the addition of a loading spinner when a load starts + // which mutates the collection? Alternatively, the user might wipe the collection during load + if (collection !== lastCollection.current && !isLoading) { + collectionAwaitingUpdate.current = false; } - // TODO: Eventually this hook will move back into RAC during which we will accept the collection as a option to this hook. - // We will only load more if the collection has changed after the last load to prevent multiple onLoadMore from being called - // while the data from the last onLoadMore is being processed by RAC collection. - let shouldLoadMore = ref?.current - && !isLoadingRef.current - && onLoadMore - && (!items || items !== lastItems.current) - && ref.current.clientHeight === ref.current.scrollHeight; - + // TODO: if we aren't loading, if the collection has changed, and the height is the same, we should load more + // if we aren't loading, if the collection is the same, and the height is the same, we are either in a case where we are still processing + // the collection and thus don't want to trigger a load or we had items preloaded and need to load more. That means comparing collection to lastCollection is + // insufficient + // might need to wait for height to change? + let shouldLoadMore = onLoadMore + && !isLoading + && !collectionAwaitingUpdate.current + && (!collection || (ref?.current && ref.current.clientHeight === ref.current.scrollHeight)); if (shouldLoadMore) { - isLoadingRef.current = true; onLoadMore?.(); + collectionAwaitingUpdate.current = true; } - lastItems.current = items; - }, [isLoading, onLoadMore, props, ref, items]); + // TODO: only update this when isLoading is false? Might need to guard against the case where loading spinners are added/collection is temporarly wiped/ + // loading spinner is removed when loading finishes (this last one we might still need to guard against somehow...). Seems to be ok for now + lastCollection.current = collection; + }, [isLoading, onLoadMore, props, ref, collection]); // TODO: maybe this should still just return scroll props? // Test against case where the ref isn't defined when this is called diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 2f846289e41..51c9835b1ed 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -47,7 +47,14 @@ export interface ComboBoxRenderProps { * Whether the combobox is required. * @selector [data-required] */ - isRequired: boolean + isRequired: boolean, + // TODO: do we want loadingState for RAC Combobox or just S2 + // TODO: move types somewhere common later + /** + * Whether the combobox is loading items. + * @selector [data-loading] + */ + isLoading?: boolean } export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { @@ -60,7 +67,11 @@ export interface ComboBoxProps extends Omit void } export const ComboBoxContext = createContext, HTMLDivElement>>(null); @@ -103,7 +114,9 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: let { name, formValue = 'key', - allowsCustomValue + allowsCustomValue, + isLoading, + onLoadMore } = props; if (allowsCustomValue) { formValue = 'text'; @@ -170,8 +183,9 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: isOpen: state.isOpen, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false - }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]); + isRequired: props.isRequired || false, + isLoading: props.isLoading || false + }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired, props.isLoading]); let renderProps = useRenderProps({ ...props, @@ -199,7 +213,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: trigger: 'ComboBox', style: {'--trigger-width': menuWidth} as React.CSSProperties }], - [ListBoxContext, {...listBoxProps, ref: listBoxRef}], + [ListBoxContext, {...listBoxProps, onLoadMore, isLoading, ref: listBoxRef}], [ListStateContext, state], [TextContext, { slots: { @@ -219,7 +233,8 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} - data-required={props.isRequired || undefined} /> + data-required={props.isRequired || undefined} + data-loading={props.isLoading || undefined} /> {name && formValue === 'key' && } ); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 9ea4b059cf3..b0a75c7b48f 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -17,8 +17,8 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils'; -import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; +import {filterDOMProps, mergeRefs, useLoadMore, useObjectRef} from '@react-aria/utils'; +import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject, StyleProps} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SeparatorContext} from './Separator'; @@ -74,7 +74,10 @@ export interface ListBoxProps extends Omit, 'children' | * direction that the collection scrolls. * @default 'vertical' */ - orientation?: Orientation + orientation?: Orientation, + // TODO: move types somewhere common later + isLoading?: boolean, + onLoadMore?: () => void } export const ListBoxContext = createContext, HTMLDivElement>>(null); @@ -92,7 +95,7 @@ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Lis // The first copy sends a collection document via context which we render the collection portal into. // The second copy sends a ListState object via context which we use to render the ListBox without rebuilding the state. // Otherwise, we have a standalone ListBox, so we need to create a collection and state ourselves. - + // console.log('in listbox render', state) if (state) { return ; } @@ -226,6 +229,20 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ); } + // console.log(' in here') + // TODO: see if we need to memo this. Also decide if scrollOffset should be settable by the user + let memoedLoadMoreProps = useMemo(() => ({ + isLoading: props.isLoading, + onLoadMore: props.onLoadMore, + collection + }), [props.isLoading, props.onLoadMore, collection]); + // TODO: maybe this should be called at the ListBox level and the StandaloneListBox level. At its current place it is only called + // when the Listbox in the dropdown is rendered + useLoadMore(memoedLoadMoreProps, listBoxRef); + + // TODO: add loading indicator to ListBox so user can render that when loading. Think about if completely empty state + // do we leave it up to the user to setup the two states for empty and empty + loading? Do we add a data attibute/prop/renderprop to ListBox + // for isLoading return ( @@ -461,3 +478,38 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe } const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); + +export interface ListBoxLoadingIndicatorProps extends StyleProps { + children?: ReactNode +} + +export const UNSTABLE_ListBoxLoadingIndicator = createLeafComponent('loader', function ListBoxLoadingIndicator(props: ListBoxLoadingIndicatorProps, ref: ForwardedRef, item: Node) { + let state = useContext(ListStateContext)!; + let {isVirtualized} = useContext(CollectionRendererContext); + + let renderProps = useRenderProps({ + ...props, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-ListBoxLoadingIndicator', + values: null + }); + let optionProps = {}; + + if (isVirtualized) { + optionProps['aria-posinset'] = state.collection.size + 1; + optionProps['aria-setsize'] = state.collection.size; + } + + return ( +
+ {renderProps.children} +
+ ); +}); diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index 35613d14b5e..d19ec32d71a 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -16,7 +16,7 @@ import {Collection, Node, SelectState, useSelectState} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, useLoadMore, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; import {forwardRefType} from '@react-types/shared'; // @ts-ignore @@ -59,10 +59,21 @@ export interface SelectRenderProps { * Whether the select is required. * @selector [data-required] */ - isRequired: boolean + isRequired: boolean, + // TODO: move types somewhere common later + /** + * Whether the select is loading items. + * @selector [data-loading] + */ + isLoading?: boolean } -export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps {} +export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps { + /** Whether the select is loading items. */ + isLoading?: boolean, + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + onLoadMore?: () => void +} export const SelectContext = createContext, HTMLDivElement>>(null); export const SelectStateContext = createContext | null>(null); @@ -151,8 +162,9 @@ function SelectInner({props, selectRef: ref, collection}: Sele isFocusVisible, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false - }), [state.isOpen, state.isFocused, isFocusVisible, props.isDisabled, validation.isInvalid, props.isRequired]); + isRequired: props.isRequired || false, + isLoading: props.isLoading || false + }), [state.isOpen, state.isFocused, isFocusVisible, props.isDisabled, validation.isInvalid, props.isRequired, props.isLoading]); let renderProps = useRenderProps({ ...props, @@ -164,6 +176,9 @@ function SelectInner({props, selectRef: ref, collection}: Sele delete DOMProps.id; let scrollRef = useRef(null); + // TODO: in select we don't call useLoadMore because we need it to trigger when the listbox opens and has a scrollref + // Perhaps we should still call the first time to populate the list? This is automatically done by useAsyncList so maybe not? + // TODO: Include a slot in the input for a loading spinner? return ( ({props, selectRef: ref, collection}: Sele style: {'--trigger-width': buttonWidth} as React.CSSProperties, 'aria-labelledby': menuProps['aria-labelledby'] }], - [ListBoxContext, {...menuProps, ref: scrollRef}], + [ListBoxContext, {...menuProps, isLoading: props.isLoading, onLoadMore: props.onLoadMore, ref: scrollRef}], [ListStateContext, state], [TextContext, { slots: { @@ -203,7 +218,8 @@ function SelectInner({props, selectRef: ref, collection}: Sele data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} - data-required={props.isRequired || undefined} /> + data-required={props.isRequired || undefined} + data-loading={props.isLoading || undefined} /> ( ); + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +export const AsyncVirtualizedDynamicCombobox = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + +
+ + +
+ + + className={styles.menu}> + {item => {item.name}} + + + +
+ ); +}; + +const MyListBoxLoaderIndicator = () => { + return ( + + + Load more spinner + + + ); +}; + +export const AsyncVirtualizedCollectionRenderCombobox = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + +
+ + +
+ + + className={styles.menu}> + + {item => ( + {item.name} + )} + + {/* TODO: loading indicator and/or renderEmpty? The spinner shows at weird times and flickers if + no delay is added, might be nice to support loadingState */} + {list.isLoading && } + + + +
+ ); +}; diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 739749126b1..9c4a19e4384 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -10,10 +10,12 @@ * governing permissions and limitations under the License. */ -import {Button, Label, ListBox, ListLayout, OverlayArrow, Popover, Select, SelectValue, Virtualizer} from 'react-aria-components'; +import {Button, Collection, 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'; +import {useAsyncList} from 'react-stately'; +import { UNSTABLE_ListBoxLoadingIndicator } from '../src/ListBox'; export default { title: 'React Aria Components' @@ -101,3 +103,107 @@ export const VirtualizedSelect = () => ( ); + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +// TODO: this might just be unrealistic since the user needs to render the loading spinner... Do we help them with that? +// They could technically do something like we do in Table.stories with MyRow where they selectively render the loading spinner +// dynamically but that feels odd +export const AsyncVirtualizedDynamicSelect = () => { + let list = useAsyncList({ + async load({signal, cursor}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + // Slow down load so progress circle can appear + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); + let json = await res.json(); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + ); +}; + +const MyListBoxLoaderIndicator = () => { + return ( + + + Load more spinner + + + ); +}; + +export const AsyncVirtualizedCollectionRenderSelect = () => { + let list = useAsyncList({ + async load({signal, cursor}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + // Slow down load so progress circle can appear + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); + let json = await res.json(); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + ); +}; From 8ee20f9a940329bd2f3968d1570298ffbd575d8c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 10 Mar 2025 12:59:56 -0700 Subject: [PATCH 02/90] test against Listbox standalone and put on content size change issue --- packages/@react-aria/utils/src/useLoadMore.ts | 6 +- .../react-aria-components/src/ListBox.tsx | 1 + .../stories/ListBox.stories.tsx | 99 ++++++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 7704a60ad67..e407c3cd6c1 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -29,13 +29,13 @@ export interface LoadMoreProps { */ scrollOffset?: number, // TODO: will need to refactor the existing components that use items + // this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? // /** The data currently loaded. */ // items?: any, collection?: Collection> } export function useLoadMore(props: LoadMoreProps, ref: RefObject) { - // let {isLoading, onLoadMore, scrollOffset = 1, items} = props; let {isLoading, onLoadMore, scrollOffset = 1, collection} = props; // Handle scrolling, and call onLoadMore when nearing the bottom. @@ -67,6 +67,10 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject extends Omit, 'children' | */ orientation?: Orientation, // TODO: move types somewhere common later + // Discuss if we want the RAC components to call the useLoadMore directly (think they have to if we wanna support using as child) isLoading?: boolean, onLoadMore?: () => void } diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index b4008633204..ba54e4fc780 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -16,7 +16,8 @@ import {MyListBoxItem} from './utils'; import React from 'react'; import {Size} from '@react-stately/virtualizer'; import styles from '../example/index.css'; -import {useListData} from 'react-stately'; +import {useAsyncList, useListData} from 'react-stately'; +import { UNSTABLE_ListBoxLoadingIndicator } from '../src/ListBox'; export default { title: 'React Aria Components' @@ -363,7 +364,7 @@ function VirtualizedListBoxGridExample({minSize = 80, maxSize = 100, preserveAsp return (
- - ); } + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const MyListBoxLoaderIndicator = () => { + return ( + + + Load more spinner + + + ); +}; + +// TODO: this doesn't have load more spinner since user basically needs to use or wrap their ListboxItem renderer so it renders the +// additional loading indicator based on list load state +export const AsyncListBox = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + list.isLoading ? 'Loading spinner' : 'No results found'}> + {item => {item.name}} + + ); +}; + +export const AsyncListBoxVirtualized = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + list.isLoading ? 'Loading spinner' : 'No results found'}> + + {item => {item.name}} + + {list.isLoading && list.items.length > 0 && } + + + ); +}; From c2a1cf976c08cdce591ecb929ee2471df7096c08 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 10 Mar 2025 15:39:57 -0700 Subject: [PATCH 03/90] update S2 CardView/RAC GridList for new useLoadMore --- packages/@react-aria/utils/src/useLoadMore.ts | 3 +- packages/@react-spectrum/s2/src/CardView.tsx | 29 +++--- .../react-aria-components/src/GridList.tsx | 54 ++++++++++- .../react-aria-components/src/ListBox.tsx | 8 +- .../stories/GridList.stories.tsx | 97 ++++++++++++++++++- 5 files changed, 167 insertions(+), 24 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index e407c3cd6c1..6ba67e75504 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -70,7 +70,8 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject extends Omit, 'layout' | 'keyboardNavigationBehavior' | 'selectionBehavior' | 'className' | 'style'>, UnsafeStyles { +export interface CardViewProps extends Omit, 'layout' | 'keyboardNavigationBehavior' | 'selectionBehavior' | 'className' | 'style' | 'isLoading'>, UnsafeStyles { /** * The layout of the cards. * @default 'grid' @@ -180,8 +180,8 @@ const cardViewStyles = style({ }, getAllowedOverrides({height: true})); const wrapperStyles = style({ - position: 'relative', - overflow: 'clip', + position: 'relative', + overflow: 'clip', size: 'fit' }, getAllowedOverrides({height: true})); @@ -189,7 +189,18 @@ export const CardViewContext = createContext(props: CardViewProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, CardViewContext); - let {children, layout: layoutName = 'grid', size: sizeProp = 'M', density = 'regular', variant = 'primary', selectionStyle = 'checkbox', UNSAFE_className = '', UNSAFE_style, styles, ...otherProps} = props; + let { + children, + layout: layoutName = 'grid', + size: sizeProp = 'M', + density = 'regular', + variant = 'primary', + selectionStyle = 'checkbox', + UNSAFE_className = '', + UNSAFE_style, + styles, + onLoadMore, + ...otherProps} = props; let domRef = useDOMRef(ref); let innerRef = useRef(null); let scrollRef = props.renderActionBar ? innerRef : domRef; @@ -224,12 +235,6 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca let layout = layoutName === 'waterfall' ? WaterfallLayout : GridLayout; let options = layoutOptions[size][density]; - useLoadMore({ - isLoading: props.loadingState !== 'idle' && props.loadingState !== 'error', - items: props.items, // TODO: ideally this would be the collection. items won't exist for static collections, or those using - onLoadMore: props.onLoadMore - }, scrollRef); - let ctx = useMemo(() => ({size, variant}), [size, variant]); let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -242,6 +247,8 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca extends Omit, 'children'> * Whether the items are arranged in a stack or grid. * @default 'stack' */ - layout?: 'stack' | 'grid' + layout?: 'stack' | 'grid', + // TODO: move types somewhere common later + // Discuss if we want the RAC components to call the useLoadMore directly (think they have to if we wanna support using as child) + isLoading?: boolean, + onLoadMore?: () => void } @@ -100,7 +104,7 @@ interface GridListInnerProps { } function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { - let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props; + let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack', isLoading, onLoadMore} = props; let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext); let state = useListState({ ...props, @@ -218,6 +222,13 @@ function GridListInner({props, collection, gridListRef: ref}: ); } + let memoedLoadMoreProps = useMemo(() => ({ + isLoading, + onLoadMore, + collection + }), [isLoading, onLoadMore, collection]); + useLoadMore(memoedLoadMoreProps, ref); + return (
); } + +export interface GridListLoadingIndicatorProps extends StyleProps { + children?: ReactNode +} + +export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingIndicatorProps, ref: ForwardedRef, item: Node) { + let state = useContext(ListStateContext)!; + let {isVirtualized} = useContext(CollectionRendererContext); + + let renderProps = useRenderProps({ + ...props, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-GridListLoadingIndicator', + values: null + }); + + return ( +
+
+ {renderProps.children} +
+
+ ); +}); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index b1433801d0e..34d3be21d4f 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -230,21 +230,21 @@ function ListBoxInner({state: inputState, props, listBoxRef}:
); } - // console.log(' in here') - // TODO: see if we need to memo this. Also decide if scrollOffset should be settable by the user + + // TODO: Should scrollOffset for useLoadMore should be configurable by the user let memoedLoadMoreProps = useMemo(() => ({ isLoading: props.isLoading, onLoadMore: props.onLoadMore, collection }), [props.isLoading, props.onLoadMore, collection]); // TODO: maybe this should be called at the ListBox level and the StandaloneListBox level. At its current place it is only called - // when the Listbox in the dropdown is rendered + // when the Listbox in the dropdown is rendered. The benefit to this would be that useLoadMore would trigger a load for the user before the + // dropdown opens but the current state gives the user more freedom as to whether they would like to pre-fetch or not useLoadMore(memoedLoadMoreProps, listBoxRef); // TODO: add loading indicator to ListBox so user can render that when loading. Think about if completely empty state // do we leave it up to the user to setup the two states for empty and empty + loading? Do we add a data attibute/prop/renderprop to ListBox // for isLoading - return (
{ + return ( + + + Load more spinner + + + ); +}; + +export const AsyncGridList = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + list.isLoading ? 'Loading spinner' : 'No results found'}> + {item => {item.name}} + + ); +}; + +export const AsyncGridListVirtualized = () => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + await new Promise(resolve => setTimeout(resolve, 4000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + list.isLoading ? 'Loading spinner' : 'No results found'}> + + {item => {item.name}} + + {list.isLoading && list.items.length > 0 && } + + + ); +}; + export function TagGroupInsideGridList() { return ( Actions - 1,3 + 1,3 Tag 1 From 765b7574d1dced2082549c6a48df9d6dfe750754 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 10 Mar 2025 16:38:08 -0700 Subject: [PATCH 04/90] fix v3 load more stories and tests --- packages/@react-aria/utils/src/useLoadMore.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 6ba67e75504..37d4e4e668d 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -50,14 +50,15 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject { // Only update this flag if the collection changes when we aren't loading. Guard against the addition of a loading spinner when a load starts // which mutates the collection? Alternatively, the user might wipe the collection during load + // If collection isn't provided, update flag if (collection !== lastCollection.current && !isLoading) { collectionAwaitingUpdate.current = false; } @@ -74,11 +75,18 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject Date: Tue, 11 Mar 2025 12:02:46 -0700 Subject: [PATCH 05/90] update table to call useLoadMore internally --- packages/@react-spectrum/s2/src/TableView.tsx | 12 ++---- packages/react-aria-components/src/Select.tsx | 2 +- packages/react-aria-components/src/Table.tsx | 15 ++++++-- .../stories/ComboBox.stories.tsx | 2 +- .../stories/GridList.stories.tsx | 2 +- .../stories/ListBox.stories.tsx | 2 +- .../stories/Select.stories.tsx | 2 +- .../stories/Table.stories.tsx | 37 +++---------------- .../react-aria-components/test/Table.test.js | 27 ++++---------- 9 files changed, 34 insertions(+), 67 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 16db0821306..6c2a0609875 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -64,7 +64,6 @@ import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLoadMore} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -110,7 +109,7 @@ interface S2TableProps { } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody -export interface TableViewProps extends Omit, UnsafeStyles, S2TableProps { +export interface TableViewProps extends Omit, UnsafeStyles, S2TableProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight } @@ -268,7 +267,6 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re overflowMode = 'truncate', styles, loadingState, - onLoadMore, onResize: propsOnResize, onResizeStart: propsOnResizeStart, onResizeEnd: propsOnResizeEnd, @@ -301,11 +299,6 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let scrollRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading: isLoading, - onLoadMore - }), [isLoading, onLoadMore]); - useLoadMore(memoedLoadMoreProps, scrollRef); let isCheckboxSelection = props.selectionMode === 'multiple' || props.selectionMode === 'single'; let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -353,7 +346,8 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} - onSelectionChange={onSelectionChange} /> + onSelectionChange={onSelectionChange} + isLoading={isLoading} /> {actionBar} diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index d19ec32d71a..f657026e508 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -16,7 +16,7 @@ import {Collection, Node, SelectState, useSelectState} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; -import {filterDOMProps, useLoadMore, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; import {forwardRefType} from '@react-types/shared'; // @ts-ignore diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 815d287adf8..f3ec69e64f9 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -10,7 +10,7 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} 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, isScrollable, mergeRefs, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, isScrollable, mergeRefs, useLayoutEffect, useLoadMore, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -324,7 +324,9 @@ export interface TableProps extends Omit, 'children'>, Sty /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Table. */ - dragAndDropHooks?: DragAndDropHooks + dragAndDropHooks?: DragAndDropHooks, + isLoading?: boolean, + onLoadMore?: () => void } /** @@ -377,7 +379,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl }); let {isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, CollectionRoot} = useContext(CollectionRendererContext); - let {dragAndDropHooks} = props; + let {dragAndDropHooks, isLoading, onLoadMore} = props; let {gridProps} = useTable({ ...props, layoutDelegate, @@ -472,6 +474,13 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl let ElementType = useElementType('table'); + let memoedLoadMoreProps = useMemo(() => ({ + isLoading, + onLoadMore, + collection + }), [isLoading, onLoadMore, collection]); + useLoadMore(memoedLoadMoreProps, tableContainerContext?.scrollRef ?? ref); + return ( { }); let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; - let scrollRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading: isLoading, - onLoadMore: list.loadMore, - items: list.items - }), [isLoading, list.loadMore, list.items]); - useLoadMore(memoedLoadMoreProps, scrollRef); - return ( - - + +
Name Height @@ -870,14 +861,6 @@ const OnLoadMoreTableVirtualized = () => { }); let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; - let scrollRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading: isLoading, - onLoadMore: list.loadMore, - items: list.items - }), [isLoading, list.loadMore, list.items]); - useLoadMore(memoedLoadMoreProps, scrollRef); - return ( { rowHeight: 25, headingHeight: 25 }}> -
+
Name Height @@ -934,23 +917,15 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { }); let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; - let scrollRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading: isLoading, - onLoadMore: list.loadMore, - items: list.items - }), [isLoading, list.loadMore, list.items]); - useLoadMore(memoedLoadMoreProps, scrollRef); - return ( - + -
+
Name Height diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3df09609895..cef7cfb28f1 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -14,11 +14,10 @@ import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, rende import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, Tag, TagGroup, TagList, useDragAndDrop, useTableOptions, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; -import React, {useMemo, useRef, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {resizingTests} from '@react-aria/table/test/tableResizingTests'; import {setInteractionModality} from '@react-aria/interactions'; import * as stories from '../stories/Table.stories'; -import {useLoadMore} from '@react-aria/utils'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -929,7 +928,7 @@ describe('Table', () => { } } }); - + describe('colSpan', () => { it('should render table with colSpans', () => { let {getAllByRole} = render(); @@ -1729,18 +1728,10 @@ describe('Table', () => { items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); } - function LoadMoreTable({onLoadMore, isLoading, scrollOffset, items}) { - let scrollRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading, - onLoadMore, - scrollOffset - }), [isLoading, onLoadMore, scrollOffset]); - useLoadMore(memoedLoadMoreProps, scrollRef); - + function LoadMoreTable({onLoadMore, isLoading, items}) { return ( - -
+ +
Foo Bar @@ -1861,7 +1852,8 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(1); }); - it('allows the user to customize the scrollOffset required to trigger onLoadMore', function () { + // TODO: decide if we want to allow customization for this (I assume we will) + it.skip('allows the user to customize the scrollOffset required to trigger onLoadMore', function () { jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1883,12 +1875,9 @@ describe('Table', () => { items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); } function VirtualizedTableLoad() { - let scrollRef = useRef(null); - useLoadMore({onLoadMore}, scrollRef); - return ( -
+
Foo Bar From 2da7d040e9969b243544e1ba0aa56af0f6982bd4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 12 Mar 2025 11:57:58 -0700 Subject: [PATCH 06/90] first attempt at refactoring useLoadmore --- packages/@react-aria/utils/src/useLoadMore.ts | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 37d4e4e668d..4e1ef680028 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -56,6 +56,10 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject { + if (!ref.current) { + return; + } + // Only update this flag if the collection changes when we aren't loading. Guard against the addition of a loading spinner when a load starts // which mutates the collection? Alternatively, the user might wipe the collection during load // If collection isn't provided, update flag @@ -63,37 +67,41 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject { + let allItemsInView = true; + entries.forEach((entry) => { + // TODO this is problematic if the entry is a long row since a part of it will always out of view meaning the intersectionRatio is < 1 + if (entry.intersectionRatio < 1) { + allItemsInView = false; + } + }); - if (shouldLoadMore) { - onLoadMore?.(); - // Only update this flag if a collection has been provided, v3 virtualizer doesn't provide a collection so we don't need - // to use collectionAwaitingUpdate at all. - if (collection !== null && lastCollection.current !== null) { - collectionAwaitingUpdate.current = true; + if (allItemsInView && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + onLoadMore(); + if (collection !== null && lastCollection.current !== null) { + collectionAwaitingUpdate.current = true; + } } + }, {root: ref.current}); + + // TODO: right now this is using the Virtualizer's div that wraps the rows, but perhaps should be the items themselves + // This also has various problems because we'll need to figure out what is the proper element to compare the scroll container height to + + let lastElement = ref.current.querySelector('[role="presentation"]'); + // let lastElement = ref.current.querySelector('[role="presentation"]>[role="presentation"]:last-child'); + if (lastElement) { + observer.observe(lastElement); } - // TODO: only update this when isLoading is false? Might need to guard against the case where loading spinners are added/collection is temporarly wiped/ - // loading spinner is removed when loading finishes (this last one we might still need to guard against somehow...). Seems to be ok for now lastCollection.current = collection; + return () => { + if (observer) { + observer.disconnect(); + } + }; }, [isLoading, onLoadMore, props, ref, collection]); + // TODO: maybe this should still just return scroll props? // Test against case where the ref isn't defined when this is called // Think this was a problem when trying to attach to the scrollable body of the table in OnLoadMoreTableBodyScroll From 0bb9f21617c42d3744ab785c9aad8bbbc27d9e03 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 13 Mar 2025 17:17:03 -0700 Subject: [PATCH 07/90] refactor useLoadMore to get rid of scroll handlers --- packages/@react-aria/utils/src/useLoadMore.ts | 92 ++++++++----------- .../virtualizer/src/ScrollView.tsx | 16 +++- .../virtualizer/src/Virtualizer.tsx | 26 +++--- .../card/test/CardView.test.js | 9 +- .../combobox/test/ComboBox.test.js | 6 +- .../listbox/test/ListBox.test.js | 14 ++- .../table/src/TableViewBase.tsx | 5 +- .../@react-spectrum/table/test/Table.test.js | 14 ++- packages/dev/test-utils/src/index.ts | 1 + .../src/mockIntersectionObserver.ts | 53 +++++++++++ .../react-aria-components/src/GridList.tsx | 9 +- .../react-aria-components/src/ListBox.tsx | 8 +- packages/react-aria-components/src/Table.tsx | 11 ++- .../react-aria-components/test/Table.test.js | 33 ++++++- scripts/setupTests.js | 14 +++ 15 files changed, 219 insertions(+), 92 deletions(-) create mode 100644 packages/dev/test-utils/src/mockIntersectionObserver.ts diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 4e1ef680028..9bb9c9b0ff5 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -11,8 +11,8 @@ */ import {Collection, Node} from '@react-types/shared'; -import {RefObject, useCallback, useRef} from 'react'; -import {useEvent} from './useEvent'; +import {RefObject, useRef} from 'react'; +import {useEffectEvent} from './useEffectEvent'; import {useLayoutEffect} from './useLayoutEffect'; export interface LoadMoreProps { @@ -28,82 +28,62 @@ export interface LoadMoreProps { * @default 1 */ scrollOffset?: number, - // TODO: will need to refactor the existing components that use items - // this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? - // /** The data currently loaded. */ - // items?: any, - collection?: Collection> + // TODO: this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? + collection?: Collection>, + /** + * A ref to a sentinel element that is positioned at the end of the list's content. The visibility of this senetinel + * with respect to the scrollable region and its offset determines if we should load more. + */ + sentinelRef: RefObject } export function useLoadMore(props: LoadMoreProps, ref: RefObject) { - let {isLoading, onLoadMore, scrollOffset = 1, collection} = props; - - // Handle scrolling, and call onLoadMore when nearing the bottom. - let onScroll = useCallback(() => { - if (ref.current && !isLoading && onLoadMore) { - let shouldLoadMore = ref.current.scrollHeight - ref.current.scrollTop - ref.current.clientHeight < ref.current.clientHeight * scrollOffset; - - if (shouldLoadMore) { - onLoadMore(); - } - } - }, [onLoadMore, isLoading, ref, scrollOffset]); - + let {isLoading, onLoadMore, scrollOffset = 1, collection, sentinelRef} = props; let lastCollection = useRef(collection); + // If we are in a loading state when this hook is called and a collection is provided, we can assume that the collection will update in the future so we don't // want to trigger another loadMore until the collection has updated as a result of the load. // TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking // this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update let collectionAwaitingUpdate = useRef(collection && isLoading); + let sentinelObserver = useRef(null); + + let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { + // Only one entry should exist so this should be ok. Also use "isIntersecting" over an equality check of 0 since it seems like there is cases where + // a intersection ratio of 0 can be reported when isIntersecting is actually true + if (entries[0].isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + onLoadMore(); + if (collection !== null && lastCollection.current !== null) { + collectionAwaitingUpdate.current = true; + } + } + }); + + // TODO: maybe can optimize creating the intersection observer by adding it in a useLayoutEffect but would need said effect to run every render + // so that we can catch when ref.current exists or is modified (maybe return a callbackRef?) and then would need to check if scrollOffset changed. useLayoutEffect(() => { if (!ref.current) { return; } - // Only update this flag if the collection changes when we aren't loading. Guard against the addition of a loading spinner when a load starts - // which mutates the collection? Alternatively, the user might wipe the collection during load - // If collection isn't provided, update flag + // Only update this flag if the collection changes when we aren't loading. Guards against cases like the addition of a loading spinner when a load starts or if the user + // temporarily wipes the collection during loading which isn't the collection update via fetch which we are waiting for. + // If collection isn't provided (aka for RSP components which won't provide a collection), flip flag to false so we still trigger onLoadMore if (collection !== lastCollection.current && !isLoading) { collectionAwaitingUpdate.current = false; } - let observer = new IntersectionObserver((entries) => { - let allItemsInView = true; - entries.forEach((entry) => { - // TODO this is problematic if the entry is a long row since a part of it will always out of view meaning the intersectionRatio is < 1 - if (entry.intersectionRatio < 1) { - allItemsInView = false; - } - }); - - if (allItemsInView && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { - onLoadMore(); - if (collection !== null && lastCollection.current !== null) { - collectionAwaitingUpdate.current = true; - } - } - }, {root: ref.current}); - - // TODO: right now this is using the Virtualizer's div that wraps the rows, but perhaps should be the items themselves - // This also has various problems because we'll need to figure out what is the proper element to compare the scroll container height to - - let lastElement = ref.current.querySelector('[role="presentation"]'); - // let lastElement = ref.current.querySelector('[role="presentation"]>[role="presentation"]:last-child'); - if (lastElement) { - observer.observe(lastElement); + sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: ref.current, rootMargin: `0px 0px ${100 * scrollOffset}% 0px`}); + if (sentinelRef?.current) { + // console.log('observing', sentinelRef.current.outerHTML) + sentinelObserver.current.observe(sentinelRef.current); } lastCollection.current = collection; return () => { - if (observer) { - observer.disconnect(); + if (sentinelObserver.current) { + sentinelObserver.current.disconnect(); } }; - }, [isLoading, onLoadMore, props, ref, collection]); - - - // TODO: maybe this should still just return scroll props? - // Test against case where the ref isn't defined when this is called - // Think this was a problem when trying to attach to the scrollable body of the table in OnLoadMoreTableBodyScroll - useEvent(ref, 'scroll', onScroll); + }, [isLoading, triggerLoadMore, ref, collection, scrollOffset, sentinelRef]); } diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 6111ed8a769..37df259455d 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -13,6 +13,7 @@ // @ts-ignore import {flushSync} from 'react-dom'; import {getScrollLeft} from './utils'; +import {inertValue, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import React, { CSSProperties, ForwardedRef, @@ -25,7 +26,6 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; interface ScrollViewProps extends HTMLAttributes { @@ -35,18 +35,24 @@ interface ScrollViewProps extends HTMLAttributes { innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, - scrollDirection?: 'horizontal' | 'vertical' | 'both' + scrollDirection?: 'horizontal' | 'vertical' | 'both', + sentinelRef: React.RefObject } +interface ScrollViewOptions extends Omit {} + function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { + let {sentinelRef, ...otherProps} = props; ref = useObjectRef(ref); - let {scrollViewProps, contentProps} = useScrollView(props, ref); + let {scrollViewProps, contentProps} = useScrollView(otherProps, ref); return (
{props.children}
+ {/* @ts-ignore - compatibility with React < 19 */} +
); } @@ -54,7 +60,7 @@ function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { +export function useScrollView(props: ScrollViewOptions, ref: RefObject) { let { contentSize, onVisibleRectChange, @@ -135,7 +141,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { return () => { if (state.scrollTimeout != null) { diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index 0bbd0e56e90..613406d2bc6 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -13,7 +13,7 @@ import {Collection, Key, RefObject} from '@react-types/shared'; import {Layout, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {mergeProps, useLoadMore, useObjectRef} from '@react-aria/utils'; -import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback} from 'react'; +import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback, useRef} from 'react'; import {ScrollView} from './ScrollView'; import {VirtualizerItem} from './VirtualizerItem'; @@ -68,21 +68,25 @@ export const Virtualizer = React.forwardRef(function Virtualizer { state.setVisibleRect(rect); }, [state]); return ( - - {renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)} - + <> + + {renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)} + + ); }) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index 9ed6a8df3e9..dcee72c2cbd 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import {Card, CardView, GalleryLayout, GridLayout, WaterfallLayout} from '../'; import {composeStories} from '@storybook/react'; import {Content} from '@react-spectrum/view'; @@ -1186,6 +1186,10 @@ describe('CardView', function () { ${'Grid layout'} | ${GridLayout} ${'Gallery layout'} | ${GalleryLayout} `('$Name CardView should call loadMore when scrolling to the bottom', async function ({layout}) { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 3000); let onLoadMore = jest.fn(); let tree = render(); @@ -1196,10 +1200,13 @@ describe('CardView', function () { let cards = tree.getAllByRole('gridcell'); expect(cards).toBeTruthy(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); let grid = tree.getByRole('grid'); grid.scrollTop = 3000; fireEvent.scroll(grid); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); scrollHeightMock.mockReset(); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 4b97d4c810d..8ed2f3df0e6 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; @@ -2074,6 +2074,7 @@ describe('ComboBox', function () { jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); }); it('onLoadMore is called on initial open', async () => { + let observer = setupIntersectionObserverMock(); load = jest .fn() .mockImplementationOnce(() => { @@ -2111,6 +2112,7 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); // update size, virtualizer raf kicks in act(() => {jest.advanceTimersToNextTimer();}); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); // onLoadMore queued by previous timer, run it now act(() => {jest.advanceTimersToNextTimer();}); @@ -2137,6 +2139,7 @@ describe('ComboBox', function () { }); it('onLoadMore is not called on when previously opened', async () => { + let observer = setupIntersectionObserverMock(); load = jest .fn() .mockImplementationOnce(() => { @@ -2173,6 +2176,7 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); // update size, virtualizer raf kicks in act(() => {jest.advanceTimersToNextTimer();}); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); // onLoadMore queued by previous timer, run it now act(() => {jest.advanceTimersToNextTimer();}); diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index 56658103c55..3987346b66d 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import Bell from '@spectrum-icons/workflow/Bell'; import {FocusExample} from '../stories/ListBox.stories'; import {Item, ListBox, Section} from '../'; @@ -886,6 +886,10 @@ describe('ListBox', function () { }); it('should fire onLoadMore when scrolling near the bottom', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); // Mock clientHeight to match maxHeight prop let maxHeight = 200; jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight); @@ -903,7 +907,7 @@ describe('ListBox', function () { return 48; }); - let {getByRole} = render( + let {getByRole, getByTestId} = render( {item => {item.name}} @@ -916,6 +920,8 @@ describe('ListBox', function () { let listbox = getByRole('listbox'); let options = within(listbox).getAllByRole('option'); expect(options.length).toBe(6); // each row is 48px tall, listbox is 200px. 5 rows fit. + 1/3 overscan + let sentinel = getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); listbox.scrollTop = 250; fireEvent.scroll(listbox); @@ -929,6 +935,7 @@ describe('ListBox', function () { // there are no more items to load at this height, so loadMore is only called twice listbox.scrollTop = 5000; fireEvent.scroll(listbox); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => jest.runAllTimers()); expect(onLoadMore).toHaveBeenCalledTimes(1); @@ -936,6 +943,7 @@ describe('ListBox', function () { }); it('should fire onLoadMore if there aren\'t enough items to fill the ListBox ', async function () { + let observer = setupIntersectionObserverMock(); // Mock clientHeight to match maxHeight prop let maxHeight = 300; jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight); @@ -1000,9 +1008,11 @@ describe('ListBox', function () { let {getByRole} = render( ); + await act(async () => { jest.runAllTimers(); }); + await act(async () => {observer.instance.triggerCallback([{isIntersecting: true}]);}); let listbox = getByRole('listbox'); let options = within(listbox).getAllByRole('option'); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 7fa21fba707..ac99d04355f 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -584,8 +584,8 @@ function TableVirtualizer(props: TableVirtualizerProps) { columnWidths: columnResizeState.columnWidths }), [columnResizeState.columnWidths]) }); - - useLoadMore({isLoading, onLoadMore, scrollOffset: 1}, bodyRef); + let sentinelRef = useRef(null); + useLoadMore({isLoading, onLoadMore, scrollOffset: 1, sentinelRef}, bodyRef); let onVisibleRectChange = useCallback((rect: Rect) => { state.setVisibleRect(rect); }, [state]); @@ -694,6 +694,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { }} innerStyle={{overflow: 'visible'}} ref={bodyRef} + sentinelRef={sentinelRef} contentSize={state.contentSize} onVisibleRectChange={onVisibleRectChangeMemo} onScrollStart={state.startScrolling} diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 8e6b2e56683..acc88fc86e7 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -12,7 +12,7 @@ jest.mock('@react-aria/live-announcer'); jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, User, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, setupIntersectionObserverMock, User, within} from '@react-spectrum/test-utils-internal'; import {ActionButton, Button} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; import {announce} from '@react-aria/live-announcer'; @@ -4346,6 +4346,11 @@ export let tableTests = () => { }); it('should fire onLoadMore when scrolling near the bottom', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); + let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 4100); let items = []; for (let i = 1; i <= 100; i++) { @@ -4371,6 +4376,8 @@ export let tableTests = () => { let body = tree.getAllByRole('rowgroup')[1]; let scrollView = body; + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); let rows = within(body).getAllByRole('row'); expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan @@ -4385,6 +4392,7 @@ export let tableTests = () => { scrollView.scrollTop = 2800; fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); @@ -4397,6 +4405,7 @@ export let tableTests = () => { let onLoadMore = jest.fn(() => { scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 2000); }); + let observer = setupIntersectionObserverMock(); let TableMock = (props) => ( @@ -4415,7 +4424,8 @@ export let tableTests = () => { ); render(); - act(() => jest.runAllTimers()); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); scrollHeightMock.mockReset(); }); diff --git a/packages/dev/test-utils/src/index.ts b/packages/dev/test-utils/src/index.ts index 7a58398a1f1..b426473b45b 100644 --- a/packages/dev/test-utils/src/index.ts +++ b/packages/dev/test-utils/src/index.ts @@ -20,3 +20,4 @@ export * from './events'; export * from './shadowDOM'; export * from './types'; export * from '@react-spectrum/test-utils'; +export * from './mockIntersectionObserver'; diff --git a/packages/dev/test-utils/src/mockIntersectionObserver.ts b/packages/dev/test-utils/src/mockIntersectionObserver.ts new file mode 100644 index 00000000000..92017790a00 --- /dev/null +++ b/packages/dev/test-utils/src/mockIntersectionObserver.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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. + */ + +export function setupIntersectionObserverMock({ + disconnect = () => null, + observe = () => null, + takeRecords = () => [], + unobserve = () => null +} = {}) { + class MockIntersectionObserver { + root; + rootMargin; + thresholds; + disconnect; + observe; + takeRecords; + unobserve; + callback; + static instance; + + constructor(cb: IntersectionObserverCallback, opts: IntersectionObserverInit = {}) { + // TODO: since we are using static to access this in the test, + // it will have the values of the latest new IntersectionObserver call + // Will replace with jsdom-testing-mocks when possible and I figure out why it blew up + // last when I tried to use it + MockIntersectionObserver.instance = this; + this.root = opts.root; + this.rootMargin = opts.rootMargin; + this.thresholds = opts.threshold; + this.disconnect = disconnect; + this.observe = observe; + this.takeRecords = takeRecords; + this.unobserve = unobserve; + this.callback = cb; + } + + triggerCallback(entries) { + this.callback(entries); + } + } + + window.IntersectionObserver = MockIntersectionObserver; + return MockIntersectionObserver; +} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 4ceed710ff5..dec12ac90de 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -18,7 +18,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; -import {filterDOMProps, useLoadMore, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, useLoadMore, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject, StyleProps} from '@react-types/shared'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -221,11 +221,12 @@ function GridListInner({props, collection, gridListRef: ref}:
); } - + let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo(() => ({ isLoading, onLoadMore, - collection + collection, + sentinelRef }), [isLoading, onLoadMore, collection]); useLoadMore(memoedLoadMoreProps, ref); @@ -255,6 +256,8 @@ function GridListInner({props, collection, gridListRef: ref}: scrollRef={ref} persistedKeys={useDndPersistedKeys(selectionManager, dragAndDropHooks, dropState)} renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> + {/* @ts-ignore - compatibility with React < 19 */} +
{emptyState} {dragPreview} diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 34d3be21d4f..1ef7fb34918 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -17,7 +17,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, mergeRefs, useLoadMore, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, mergeRefs, useLoadMore, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject, StyleProps} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -231,11 +231,13 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ); } + let sentinelRef = useRef(null); // TODO: Should scrollOffset for useLoadMore should be configurable by the user let memoedLoadMoreProps = useMemo(() => ({ isLoading: props.isLoading, onLoadMore: props.onLoadMore, - collection + collection, + sentinelRef }), [props.isLoading, props.onLoadMore, collection]); // TODO: maybe this should be called at the ListBox level and the StandaloneListBox level. At its current place it is only called // when the Listbox in the dropdown is rendered. The benefit to this would be that useLoadMore would trigger a load for the user before the @@ -274,6 +276,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: scrollRef={listBoxRef} persistedKeys={useDndPersistedKeys(selectionManager, dragAndDropHooks, dropState)} renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> + {/* @ts-ignore - compatibility with React < 19 */} +
{emptyState} {dragPreview} diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index f3ec69e64f9..dc0ad550bb0 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -10,7 +10,7 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} 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, isScrollable, mergeRefs, useLayoutEffect, useLoadMore, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, inertValue, isScrollable, mergeRefs, useLayoutEffect, useLoadMore, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -474,12 +474,17 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl let ElementType = useElementType('table'); + let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo(() => ({ isLoading, onLoadMore, - collection + collection, + sentinelRef }), [isLoading, onLoadMore, collection]); useLoadMore(memoedLoadMoreProps, tableContainerContext?.scrollRef ?? ref); + // TODO: double check this, can't render the sentinel as a div for non-virtualized cases, theoretically + // the inert should hide this from the accessbility tree anyways + let TBody = useElementType('tbody'); return ( + {/* @ts-ignore - compatibility with React < 19 */} +
{dragPreview} diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index cef7cfb28f1..105854308d2 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, triggerLongPress, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, triggerLongPress, within} from '@react-spectrum/test-utils-internal'; import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, Tag, TagGroup, TagList, useDragAndDrop, useTableOptions, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; @@ -227,9 +227,11 @@ describe('Table', () => { } let rowGroups = tableTester.rowGroups; - expect(rowGroups).toHaveLength(2); + expect(rowGroups).toHaveLength(3); expect(rowGroups[0]).toHaveAttribute('class', 'react-aria-TableHeader'); expect(rowGroups[1]).toHaveAttribute('class', 'react-aria-TableBody'); + // Sentinel element for loadmore + expect(rowGroups[2]).toHaveAttribute('inert'); for (let cell of tableTester.columns) { expect(cell).toHaveAttribute('class', 'react-aria-Column'); @@ -261,9 +263,10 @@ describe('Table', () => { } let rowGroups = getAllByRole('rowgroup'); - expect(rowGroups).toHaveLength(2); + expect(rowGroups).toHaveLength(3); expect(rowGroups[0]).toHaveAttribute('class', 'table-header'); expect(rowGroups[1]).toHaveAttribute('class', 'table-body'); + expect(rowGroups[2]).toHaveAttribute('inert'); for (let cell of getAllByRole('columnheader')) { expect(cell).toHaveAttribute('class', 'column'); @@ -295,7 +298,7 @@ describe('Table', () => { } let rowGroups = getAllByRole('rowgroup'); - expect(rowGroups).toHaveLength(2); + expect(rowGroups).toHaveLength(3); expect(rowGroups[0]).toHaveAttribute('data-testid', 'table-header'); expect(rowGroups[1]).toHaveAttribute('data-testid', 'table-body'); @@ -1754,6 +1757,10 @@ describe('Table', () => { }); it('should fire onLoadMore when scrolling near the bottom', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1761,6 +1768,9 @@ describe('Table', () => { let scrollView = tree.getByTestId('scrollRegion'); expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(sentinel.nodeName).toBe('TBODY'); scrollView.scrollTop = 50; fireEvent.scroll(scrollView); @@ -1770,12 +1780,14 @@ describe('Table', () => { scrollView.scrollTop = 76; fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); it('doesn\'t call onLoadMore if it is already loading items', function () { + let observer = setupIntersectionObserverMock(); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1793,16 +1805,19 @@ describe('Table', () => { tree.rerender(); fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () { + let observer = setupIntersectionObserverMock(); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); let tree = render(); tree.rerender(); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); @@ -1870,6 +1885,10 @@ describe('Table', () => { }); it('works with virtualizer', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); let items = []; for (let i = 0; i < 6; i++) { items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); @@ -1905,10 +1924,13 @@ describe('Table', () => { return 25; }); - let {getByRole} = render(); + let {getByRole, getByTestId} = render(); let scrollView = getByRole('grid'); expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(sentinel.nodeName).toBe('DIV'); scrollView.scrollTop = 50; fireEvent.scroll(scrollView); @@ -1918,6 +1940,7 @@ describe('Table', () => { scrollView.scrollTop = 76; fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); diff --git a/scripts/setupTests.js b/scripts/setupTests.js index 2e8a07c3a77..9565be18aec 100644 --- a/scripts/setupTests.js +++ b/scripts/setupTests.js @@ -95,3 +95,17 @@ expect.extend({ failTestOnConsoleWarn(); failTestOnConsoleError(); + +beforeEach(() => { + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null + }); + window.IntersectionObserver = mockIntersectionObserver; +}); + +afterEach(() => { + delete window.IntersectionObserver; +}); From 004915390c46dd005b086730c28520f2b71c0af4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 14 Mar 2025 11:35:08 -0700 Subject: [PATCH 08/90] Add S2 Picker async support, support horizontal scrolling, fix types and data-attributes --- packages/@react-aria/utils/src/useLoadMore.ts | 3 +- packages/@react-spectrum/s2/src/Picker.tsx | 73 ++++++++++++++++++- .../s2/stories/Picker.stories.tsx | 41 +++++++++++ .../react-aria-components/src/ComboBox.tsx | 12 +-- .../react-aria-components/src/GridList.tsx | 25 ++++--- .../react-aria-components/src/ListBox.tsx | 25 ++++--- packages/react-aria-components/src/Select.tsx | 11 +-- packages/react-aria-components/src/Table.tsx | 21 ++++-- packages/react-aria-components/src/index.ts | 4 +- 9 files changed, 161 insertions(+), 54 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 9bb9c9b0ff5..14e37e5f133 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -73,9 +73,8 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject + + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {isLoading && loadingSpinner} + + ); + } else { + renderer = ( + <> + {children} + {isLoading && loadingSpinner} + + ); + } + return ( {isInvalid && ( + // TODO: in Figma it shows the icon as being disabled when loading, confirm with spectrum )} + {isLoading && ( + + )} + className={iconStyles({isLoading})} /> {isFocusVisible && isQuiet && } {isInvalid && !isDisabled && !isQuiet && // @ts-ignore known limitation detecting functions from the theme @@ -414,7 +479,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick - {children} + {renderer} diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 0520c236b9e..82062327e03 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -29,6 +29,7 @@ import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg'; import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style' with {type: 'macro'}; +import {useAsyncList} from '@react-stately/data'; const meta: Meta> = { component: Picker, @@ -201,3 +202,43 @@ export const ContextualHelpExample = { label: 'Ice cream flavor' } }; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const AsyncPicker = (args: any) => { + let list = useAsyncList({ + async load({signal, cursor}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + // Slow down load so progress circle can appear + await new Promise(resolve => setTimeout(resolve, 2000)); + let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); + let json = await res.json(); + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + {(item: Character) => {item.name}} + + ); +}; + +export const AsyncPickerStory = { + render: AsyncPicker, + args: { + ...Example.args + }, + name: 'Async loading picker' +}; diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 51c9835b1ed..5db5b454473 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria'; +import {AsyncLoadable, forwardRefType, RefObject} from '@react-types/shared'; import {ButtonContext} from './Button'; import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; @@ -17,7 +18,6 @@ import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps import {FieldErrorContext} from './FieldError'; import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; -import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; @@ -51,13 +51,13 @@ export interface ComboBoxRenderProps { // TODO: do we want loadingState for RAC Combobox or just S2 // TODO: move types somewhere common later /** - * Whether the combobox is loading items. + * Whether the combobox is currently loading items. * @selector [data-loading] */ isLoading?: boolean } -export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { +export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, AsyncLoadable { /** The filter function used to determine if a option should be included in the combo box list. */ defaultFilter?: (textValue: string, inputValue: string) => boolean, /** @@ -67,11 +67,7 @@ export interface ComboBoxProps extends Omit void + allowsEmptyCollection?: boolean } export const ComboBoxContext = createContext, HTMLDivElement>>(null); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index dec12ac90de..96f114ebd39 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -10,16 +10,16 @@ * governing permissions and limitations under the License. */ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria'; +import {AsyncLoadable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; -import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; +import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; import {filterDOMProps, inertValue, useLoadMore, useObjectRef} from '@react-aria/utils'; -import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject, StyleProps} from '@react-types/shared'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {TextContext} from './Text'; @@ -53,10 +53,15 @@ export interface GridListRenderProps { /** * State of the grid list. */ - state: ListState + state: ListState, + /** + * Whether the grid list is currently loading items. + * @selector [data-loading] + */ + isLoading?: boolean } -export interface GridListProps extends Omit, 'children'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps { +export interface GridListProps extends Omit, 'children'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, AsyncLoadable { /** * Whether typeahead navigation is disabled. * @default false @@ -72,11 +77,7 @@ export interface GridListProps extends Omit, 'children'> * Whether the items are arranged in a stack or grid. * @default 'stack' */ - layout?: 'stack' | 'grid', - // TODO: move types somewhere common later - // Discuss if we want the RAC components to call the useLoadMore directly (think they have to if we wanna support using as child) - isLoading?: boolean, - onLoadMore?: () => void + layout?: 'stack' | 'grid' } @@ -200,7 +201,8 @@ function GridListInner({props, collection, gridListRef: ref}: isFocused, isFocusVisible, layout, - state + state, + isLoading: isLoading || false }; let renderProps = useRenderProps({ className: props.className, @@ -243,7 +245,8 @@ function GridListInner({props, collection, gridListRef: ref}: data-empty={state.collection.size === 0 || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} - data-layout={layout}> + data-layout={layout} + data-loading={isLoading || undefined}> + state: ListState, + /** + * Whether the listbox is currently loading items. + * @selector [data-loading] + */ + isLoading?: boolean } -export interface ListBoxProps extends Omit, 'children' | 'label'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps { +export interface ListBoxProps extends Omit, 'children' | 'label'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, AsyncLoadable { /** How multiple selection should behave in the collection. */ selectionBehavior?: SelectionBehavior, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the ListBox. */ @@ -74,11 +79,7 @@ export interface ListBoxProps extends Omit, 'children' | * direction that the collection scrolls. * @default 'vertical' */ - orientation?: Orientation, - // TODO: move types somewhere common later - // Discuss if we want the RAC components to call the useLoadMore directly (think they have to if we wanna support using as child) - isLoading?: boolean, - onLoadMore?: () => void + orientation?: Orientation } export const ListBoxContext = createContext, HTMLDivElement>>(null); @@ -210,7 +211,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: isFocused, isFocusVisible, layout: props.layout || 'stack', - state + state, + isLoading: props.isLoading || false }; let renderProps = useRenderProps({ className: props.className, @@ -261,7 +263,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-layout={props.layout || 'stack'} - data-orientation={props.orientation || 'vertical'}> + data-orientation={props.orientation || 'vertical'} + data-loading={props.isLoading || undefined}> extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps { - /** Whether the select is loading items. */ - isLoading?: boolean, - /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ - onLoadMore?: () => void -} +export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps, AsyncLoadable {} export const SelectContext = createContext, HTMLDivElement>>(null); export const SelectStateContext = createContext | null>(null); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index dc0ad550bb0..298bae1cf54 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1,4 +1,4 @@ -import {AriaLabelingProps, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; +import {AriaLabelingProps, AsyncLoadable, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, useCachedChildren} from '@react-aria/collections'; import {buildHeaderRows, TableColumnResizeState} from '@react-stately/table'; import {ButtonContext} from './Button'; @@ -305,10 +305,15 @@ export interface TableRenderProps { /** * State of the table. */ - state: TableState + state: TableState, + /** + * Whether the table is currently loading items. + * @selector [data-loading] + */ + isLoading?: boolean } -export interface TableProps extends Omit, 'children'>, StyleRenderProps, SlotProps, AriaLabelingProps, ScrollableProps { +export interface TableProps extends Omit, 'children'>, StyleRenderProps, SlotProps, AriaLabelingProps, ScrollableProps, AsyncLoadable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children?: ReactNode, /** @@ -324,9 +329,7 @@ export interface TableProps extends Omit, 'children'>, Sty /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Table. */ - dragAndDropHooks?: DragAndDropHooks, - isLoading?: boolean, - onLoadMore?: () => void + dragAndDropHooks?: DragAndDropHooks } /** @@ -451,7 +454,8 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isDropTarget: isRootDropTarget, isFocused, isFocusVisible, - state + state, + isLoading: isLoading || false } }); @@ -506,7 +510,8 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl data-allows-dragging={isListDraggable || undefined} data-drop-target={isRootDropTarget || undefined} data-focused={isFocused || undefined} - data-focus-visible={isFocusVisible || undefined}> + data-focus-visible={isFocusVisible || undefined} + data-loading={props.isLoading || undefined}> Date: Fri, 14 Mar 2025 15:34:36 -0700 Subject: [PATCH 09/90] async support for S2 combobox --- packages/@react-spectrum/s2/intl/en-US.json | 3 +- packages/@react-spectrum/s2/src/ComboBox.tsx | 468 ++++++++++++------ packages/@react-spectrum/s2/src/Picker.tsx | 59 ++- .../s2/stories/ComboBox.stories.tsx | 64 ++- 4 files changed, 412 insertions(+), 182 deletions(-) diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index 38217e1a3cc..32876dc81d5 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -5,6 +5,7 @@ "actionbar.actions": "Actions", "actionbar.actionsAvailable": "Actions available.", "button.pending": "pending", + "combobox.noResults": "No results", "contextualhelp.info": "Information", "contextualhelp.help": "Help", "dialog.dismiss": "Dismiss", @@ -30,4 +31,4 @@ "tag.actions": "Actions", "tag.noTags": "None", "breadcrumbs.more": "More items" -} \ No newline at end of file +} diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 58468d2265f..d4d791fabe3 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -16,6 +16,8 @@ import { ListBoxSection as AriaListBoxSection, PopoverProps as AriaPopoverProps, Button, + Collection, + ComboBoxStateContext, ContextValue, InputContext, ListBox, @@ -23,7 +25,8 @@ import { ListBoxItemProps, ListBoxProps, Provider, - SectionProps + SectionProps, + UNSTABLE_ListBoxLoadingIndicator } from 'react-aria-components'; import {baseColor, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; @@ -41,24 +44,27 @@ import { } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; +import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; -import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; +import {HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {menu} from './Picker'; import {mergeRefs, useResizeObserver} from '@react-aria/utils'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {pressScale} from './pressScale'; +import {ProgressCircle} from './ProgressCircle'; import {TextFieldRef} from '@react-types/textfield'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; - export interface ComboboxStyleProps { /** * The size of the Combobox. @@ -68,7 +74,7 @@ export interface ComboboxStyleProps { size?: 'S' | 'M' | 'L' | 'XL' } export interface ComboBoxProps extends - Omit, 'children' | 'style' | 'className' | 'defaultFilter' | 'allowsEmptyCollection'>, + Omit, 'children' | 'style' | 'className' | 'defaultFilter' | 'allowsEmptyCollection' | 'isLoading'>, ComboboxStyleProps, StyleProps, SpectrumLabelableProps, @@ -78,7 +84,7 @@ export interface ComboBoxProps extends /** The contents of the collection. */ children: ReactNode | ((item: T) => ReactNode), /** - * Direction the menu will render relative to the Picker. + * Direction the menu will render relative to the ComboBox. * * @default 'bottom' */ @@ -90,7 +96,9 @@ export interface ComboBoxProps extends */ align?: 'start' | 'end', /** Width of the menu. By default, matches width of the trigger. Note that the minimum width of the dropdown is always equal to the trigger's width. */ - menuWidth?: number + menuWidth?: number, + /** The current loading state of the ComboBox. Determines whether or not the progress circle should be shown. */ + loadingState?: LoadingState } export const ComboBoxContext = createContext>, TextFieldRef>>(null); @@ -147,6 +155,43 @@ const iconStyles = style({ } }); +const loadingWrapperStyles = style({ + gridColumnStart: '1', + gridColumnEnd: '-1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginY: 8 +}); + +const progressCircleStyles = style({ + size: { + size: { + S: 16, + M: 20, + L: 22, + XL: 26 + } + }, + marginStart: { + isInput: 'text-to-visual' + } +}); + +const emptyStateText = style({ + font: { + size: { + S: 'ui-sm', + M: 'ui', + L: 'ui-lg', + XL: 'ui-xl' + } + }, + display: 'flex', + alignItems: 'center', + paddingStart: 'edge-to-text' +}); + let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); /** @@ -154,75 +199,22 @@ let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({siz */ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function ComboBox(props: ComboBoxProps, ref: Ref) { [props, ref] = useSpectrumContextProps(props, ref, ComboBoxContext); - let inputRef = useRef(null); - let domRef = useRef(null); - let buttonRef = useRef(null); + let formContext = useContext(FormContext); props = useFormProps(props); let { - direction = 'bottom', - align = 'start', - shouldFlip = true, - menuWidth, - label, - description: descriptionMessage, - errorMessage, - children, - items, size = 'M', labelPosition = 'top', - labelAlign = 'start', - necessityIndicator, UNSAFE_className = '', UNSAFE_style, - ...pickerProps + loadingState, + ...comboBoxProps } = props; - // Expose imperative interface for ref - useImperativeHandle(ref, () => ({ - ...createFocusableRef(domRef, inputRef), - select() { - if (inputRef.current) { - inputRef.current.select(); - } - }, - getInputElement() { - return inputRef.current; - } - })); - - // Better way to encode this into a style? need to account for flipping - let menuOffset: number; - if (size === 'S') { - menuOffset = 6; - } else if (size === 'M') { - menuOffset = 6; - } else if (size === 'L') { - menuOffset = 7; - } else { - menuOffset = 8; - } - - let triggerRef = useRef(null); - // Make menu width match input + button - let [triggerWidth, setTriggerWidth] = useState(null); - let onResize = useCallback(() => { - if (triggerRef.current) { - let inputRect = triggerRef.current.getBoundingClientRect(); - let minX = inputRect.left; - let maxX = inputRect.right; - setTriggerWidth((maxX - minX) + 'px'); - } - }, [triggerRef, setTriggerWidth]); - - useResizeObserver({ - ref: triggerRef, - onResize: onResize - }); - return ( {({isDisabled, isOpen, isRequired, isInvalid}) => ( - <> - - - {label} - - - - {ctx => ( - - - - )} - - {isInvalid && } - - - - {errorMessage} - - - - - {children} - - - - - + )} ); }); - export interface ComboBoxItemProps extends Omit, StyleProps { children: ReactNode } @@ -391,3 +292,256 @@ export function ComboBoxSection(props: ComboBoxSectionProps ); } + +// TODO: not quite sure why typescript is complaining when I types this as T extends object +const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps & {isOpen: boolean}, ref: ForwardedRef) { + let { + direction = 'bottom', + align = 'start', + shouldFlip = true, + menuWidth, + label, + description: descriptionMessage, + errorMessage, + children, + items, + size = 'M', + labelPosition = 'top', + labelAlign = 'start', + necessityIndicator, + loadingState, + isDisabled, + isOpen, + isRequired, + isInvalid, + menuTrigger + } = props; + + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let inputRef = useRef(null); + let domRef = useRef(null); + let buttonRef = useRef(null); + // Expose imperative interface for ref + useImperativeHandle(ref, () => ({ + ...createFocusableRef(domRef, inputRef), + select() { + if (inputRef.current) { + inputRef.current.select(); + } + }, + getInputElement() { + return inputRef.current; + } + })); + + // Better way to encode this into a style? need to account for flipping + let menuOffset: number; + if (size === 'S') { + menuOffset = 6; + } else if (size === 'M') { + menuOffset = 6; + } else if (size === 'L') { + menuOffset = 7; + } else { + menuOffset = 8; + } + + let triggerRef = useRef(null); + // Make menu width match input + button + let [triggerWidth, setTriggerWidth] = useState(null); + let onResize = useCallback(() => { + if (triggerRef.current) { + let inputRect = triggerRef.current.getBoundingClientRect(); + let minX = inputRect.left; + let maxX = inputRect.right; + setTriggerWidth((maxX - minX) + 'px'); + } + }, [triggerRef, setTriggerWidth]); + + useResizeObserver({ + ref: triggerRef, + onResize: onResize + }); + + let state = useContext(ComboBoxStateContext); + let timeout = useRef | null>(null); + let [showLoading, setShowLoading] = useState(false); + let isLoading = loadingState === 'loading' || loadingState === 'filtering'; + + let inputValue = state?.inputValue; + let lastInputValue = useRef(inputValue); + useEffect(() => { + if (isLoading && !showLoading) { + if (timeout.current === null) { + timeout.current = setTimeout(() => { + setShowLoading(true); + }, 500); + } + + // If user is typing, clear the timer and restart since it is a new request + if (inputValue !== lastInputValue.current) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + setShowLoading(true); + }, 500); + } + } else if (!isLoading) { + // If loading is no longer happening, clear any timers and hide the loading circle + setShowLoading(false); + if (timeout.current) { + clearTimeout(timeout.current); + } + timeout.current = null; + } + + lastInputValue.current = inputValue; + }, [isLoading, showLoading, inputValue]); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + timeout.current = null; + }; + }, []); + + let renderer; + let listBoxLoadingCircle = ( + + + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {loadingState === 'loadingMore' && listBoxLoadingCircle} + + ); + } else { + renderer = ( + <> + {children} + {loadingState === 'loadingMore' && listBoxLoadingCircle} + + ); + } + + return ( + <> + + + {label} + + + + {ctx => ( + + + + )} + + {isInvalid && } + {/* Logic copied from S1 */} + {showLoading && (isOpen || menuTrigger === 'manual' || loadingState === 'loading') && ( + + )} + + + + {errorMessage} + + + + loadingState != null && ( + + {loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')} + + )} + items={items} + isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} + className={menu({size})}> + {renderer} + + + + + + ); +}); diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 7f2155e66b5..469eb875f6b 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -26,6 +26,7 @@ import { ListBoxProps, Provider, SectionProps, + SelectStateContext, SelectValue, UNSTABLE_ListBoxLoadingIndicator } from 'react-aria-components'; @@ -222,6 +223,9 @@ const valueStyles = style({ alignItems: 'center' }); +// TODO: the designs show that it should be disabled when loading, but I think that should +// only apply if there aren't any items in the picker. What do we think? I could also do the same +// for the button and make it have disabled styles const iconStyles = style({ flexShrink: 0, rotate: 90, @@ -230,7 +234,7 @@ const iconStyles = style({ value: 'currentColor' }, color: { - isLoading: 'disabled' + isInitialLoad: 'disabled' } }); @@ -317,15 +321,19 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick // TODO: no designs for the spinner in the listbox that I've seen so will need to double check let renderer; - let loadingSpinner = ( + let loadingCircle = ( + + ); + + let listBoxLoadingCircle = ( - + {loadingCircle} ); @@ -335,14 +343,14 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick {children} - {isLoading && loadingSpinner} + {isLoading && listBoxLoadingCircle} ); } else { renderer = ( <> {children} - {isLoading && loadingSpinner} + {isLoading && listBoxLoadingCircle} ); } @@ -416,19 +424,9 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick ); }} - {isInvalid && ( - // TODO: in Figma it shows the icon as being disabled when loading, confirm with spectrum - - )} - {isLoading && ( - - )} - + {isInvalid && } + {isLoading && loadingCircle} + {isFocusVisible && isQuiet && } {isInvalid && !isDisabled && !isQuiet && // @ts-ignore known limitation detecting functions from the theme @@ -477,6 +475,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }] ]}> {renderer} @@ -562,3 +561,17 @@ export function PickerSection(props: PickerSectionProps) { ); } + +interface ChevronProps extends Pick, 'size' | 'isLoading'> {} + +function Chevron(props: ChevronProps) { + let {size, isLoading} = props; + let state = useContext(SelectStateContext); + // If it is the initial load, the collection either hasn't been formed or only has the loader so apply the disabled style + let isInitialLoad = (state?.collection.size == null || state?.collection.size <= 1) && isLoading; + return ( + + ); +} diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 802b7971840..26b5e96a065 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -17,6 +17,7 @@ import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg'; import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style' with {type: 'macro'}; +import {useAsyncList} from 'react-stately'; const meta: Meta> = { component: ComboBox, @@ -98,7 +99,6 @@ export const Dynamic: Story = { } }; - export const WithIcons: Story = { render: (args) => ( @@ -185,3 +185,65 @@ export const CustomWidth = { } } }; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const AsyncComboBox = (args: any) => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + // Slow down load so progress circle can appear + await new Promise(resolve => setTimeout(resolve, 2000)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + {(item: Character) => {item.name}} + + ); +}; + +export const AsyncComboBoxStory = { + render: AsyncComboBox, + args: { + ...Example.args, + label: 'Star Wars Character Lookup' + }, + name: 'Async loading combobox' +}; + +export const EmptyCombobox = { + render: (args) => ( + + {[]} + + ), + args: Example.args, + parameters: { + docs: { + disable: true + } + } +}; From 54fcbaa18cc9247ea68214bc38c6db3a6ecbee5e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 14 Mar 2025 17:27:44 -0700 Subject: [PATCH 10/90] fix lint and add horizontal scrolling story --- .../virtualizer/src/ScrollView.tsx | 2 -- .../react-aria-components/src/ListBox.tsx | 1 - .../stories/ListBox.stories.tsx | 36 ++++++++++++++++--- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7d906578d37..4b88a42c177 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -39,8 +39,6 @@ interface ScrollViewProps extends HTMLAttributes { sentinelRef: React.RefObject } - - function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { let {sentinelRef, ...otherProps} = props; ref = useObjectRef(ref); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index ba079358dab..0b5628b61d4 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -97,7 +97,6 @@ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Lis // The first copy sends a collection document via context which we render the collection portal into. // The second copy sends a ListState object via context which we use to render the ListBox without rebuilding the state. // Otherwise, we have a standalone ListBox, so we need to create a collection and state ourselves. - // console.log('in listbox render', state) if (state) { return ; } diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index d78d0a6dcca..c0f3e605a8f 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -450,7 +450,7 @@ const MyListBoxLoaderIndicator = () => { // TODO: this doesn't have load more spinner since user basically needs to use or wrap their ListboxItem renderer so it renders the // additional loading indicator based on list load state -export const AsyncListBox = () => { +export const AsyncListBox = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { if (cursor) { @@ -470,18 +470,46 @@ export const AsyncListBox = () => { return ( list.isLoading ? 'Loading spinner' : 'No results found'}> - {item => {item.name}} + {(item: Character) => ( + + {item.name} + + )} ); }; +AsyncListBox.story = { + args: { + orientation: 'horizontal' + }, + argTypes: { + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'] + } + } +}; + export const AsyncListBoxVirtualized = () => { let list = useAsyncList({ async load({signal, cursor, filterText}) { From 4d3bb6c41555cf1b81e2e720e7e0d4a04a6833ed Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 17 Mar 2025 10:16:39 -0700 Subject: [PATCH 11/90] hack together async listbox virtualized example --- .../react-aria-components/src/Virtualizer.tsx | 4 +- .../stories/ListBox.stories.tsx | 111 ++++++++++++++++-- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index affe32c2442..3334eff7700 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -107,7 +107,9 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat } return ( -
+ // TODO: temporarily hack styling so the load more sentinel is properly positioned + // (aka we need the virtualizer content wrapper to take its full height/width if its is in a flex parent) +
{renderChildren(null, state.visibleViews, renderDropIndicator)} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index c0f3e605a8f..5cd9d4e8fa0 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -12,12 +12,12 @@ import {action} from '@storybook/addon-actions'; import {Collection, DropIndicator, GridLayout, Header, ListBox, ListBoxItem, ListBoxProps, ListBoxSection, ListLayout, Separator, Text, useDragAndDrop, Virtualizer, WaterfallLayout} from 'react-aria-components'; +import {Key, useAsyncList, useListData} from 'react-stately'; +import {Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; import {MyListBoxItem} from './utils'; -import React from 'react'; -import {Size} from '@react-stately/virtualizer'; +import React, {useMemo} from 'react'; import styles from '../example/index.css'; import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; -import {useAsyncList, useListData} from 'react-stately'; export default { title: 'React Aria Components' @@ -510,7 +510,63 @@ AsyncListBox.story = { } }; -export const AsyncListBoxVirtualized = () => { +class HorizontalLayout extends Layout { + protected rowWidth: number; + + constructor(options) { + super(); + this.rowWidth = options.rowWidth ?? 100; + } + + // Determine which items are visible within the given rectangle. + getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { + let virtualizer = this.virtualizer!; + let keys = Array.from(virtualizer.collection.getKeys()); + let startIndex = Math.max(0, Math.floor(rect.x / 100)); + let endIndex = Math.min(keys.length - 1, Math.ceil(rect.maxX / 100)); + let layoutInfos = [] as LayoutInfo[]; + for (let i = startIndex; i <= endIndex; i++) { + let layoutInfo = this.getLayoutInfo(keys[i]); + if (layoutInfo) { + layoutInfos.push(layoutInfo); + } + } + + // Always add persisted keys (e.g. the focused item), even when out of view. + for (let key of virtualizer.persistedKeys) { + let item = virtualizer.collection.getItem(key); + let layoutInfo = this.getLayoutInfo(key); + if (item?.index && layoutInfo) { + if (item?.index < startIndex) { + layoutInfos.unshift(layoutInfo); + } else if (item?.index > endIndex) { + layoutInfos.push(layoutInfo); + } + } + } + + return layoutInfos; + } + + // Provide a LayoutInfo for a specific item. + getLayoutInfo(key: Key): LayoutInfo | null { + let node = this.virtualizer!.collection.getItem(key); + if (!node) { + return null; + } + + let rect = new Rect(node.index * this.rowWidth, 0, this.rowWidth, 100); + return new LayoutInfo(node.type, node.key, rect); + } + + // Provide the total size of all items. + getContentSize(): Size { + let numItems = this.virtualizer!.collection.size; + return new Size(numItems * this.rowWidth, 100); + } +} + +export const AsyncListBoxVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { if (cursor) { @@ -528,25 +584,56 @@ export const AsyncListBoxVirtualized = () => { } }); + let layout = useMemo(() => { + return args.orientation === 'horizontal' ? new HorizontalLayout({rowWidth: 100}) : new ListLayout({rowHeight: 50, padding: 4}); + }, [args.orientation]); return ( + layout={layout}> list.isLoading ? 'Loading spinner' : 'No results found'}> - {item => {item.name}} + {(item: Character) => ( + + {item.name} + + )} {list.isLoading && list.items.length > 0 && } ); }; + +AsyncListBoxVirtualized.story = { + args: { + orientation: 'horizontal' + }, + argTypes: { + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'] + } + } +}; From 3fe09f6bbbe18950b97cca3e7d5a9b7a14ab0bb1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 18 Mar 2025 11:33:12 -0700 Subject: [PATCH 12/90] add loading spinners to RAC stories --- .../react-aria-components/example/index.css | 6 ++++ .../stories/ComboBox.stories.tsx | 30 ++++++++++++------- .../stories/GridList.stories.tsx | 23 +++++++++----- .../stories/ListBox.stories.tsx | 24 +++++++++------ .../stories/Select.stories.tsx | 22 +++++++------- .../stories/Table.stories.tsx | 27 +++++++++-------- .../react-aria-components/stories/utils.tsx | 19 +++++++++++- 7 files changed, 99 insertions(+), 52 deletions(-) diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css index 3a56707c261..c29477c6730 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -474,3 +474,9 @@ input { [aria-autocomplete][data-focus-visible]{ outline: 3px solid blue; } + +.spinner { + position: absolute; + top: 50%; + left: 50%; +} diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 81019ab5c03..ed5245e80ae 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -11,7 +11,7 @@ */ import {Button, Collection, ComboBox, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components'; -import {MyListBoxItem} from './utils'; +import {LoadingSpinner, MyListBoxItem} from './utils'; import React, {useMemo, useState} from 'react'; import styles from '../example/index.css'; import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; @@ -238,6 +238,14 @@ export const VirtualizedComboBox = () => { ); }; +let renderEmptyState = ({isLoading}) => { + return ( +
+ {isLoading ? : 'No results'} +
+ ); +}; + interface Character { name: string, height: number, @@ -252,7 +260,7 @@ export const AsyncVirtualizedDynamicCombobox = () => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -264,17 +272,18 @@ export const AsyncVirtualizedDynamicCombobox = () => { }); return ( - + -
+
+ {list.isLoading && }
- className={styles.menu}> + className={styles.menu} renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> {item => {item.name}} @@ -285,10 +294,8 @@ export const AsyncVirtualizedDynamicCombobox = () => { const MyListBoxLoaderIndicator = () => { return ( - - - Load more spinner - + + ); }; @@ -300,7 +307,7 @@ export const AsyncVirtualizedCollectionRenderCombobox = () => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -314,8 +321,9 @@ export const AsyncVirtualizedCollectionRenderCombobox = () => { return ( -
+
+ {list.isLoading && } diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 358d2db0ef6..d4e5cca10bc 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -16,6 +16,7 @@ import React from 'react'; import styles from '../example/index.css'; import {UNSTABLE_GridListLoadingIndicator} from '../src/GridList'; import {useAsyncList, useListData} from 'react-stately'; +import { LoadingSpinner } from './utils'; export default { title: 'React Aria Components' @@ -171,6 +172,14 @@ export function VirtualizedGridListGrid() { ); } +let renderEmptyState = ({isLoading}) => { + return ( +
+ {isLoading ? : 'No results'} +
+ ); +}; + interface Character { name: string, height: number, @@ -180,10 +189,8 @@ interface Character { const MyGridListLoaderIndicator = () => { return ( - - - Load more spinner - + + ); }; @@ -195,7 +202,7 @@ export const AsyncGridList = () => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -214,7 +221,7 @@ export const AsyncGridList = () => { aria-label="async gridlist" isLoading={list.isLoading} onLoadMore={list.loadMore} - renderEmptyState={() => list.isLoading ? 'Loading spinner' : 'No results found'}> + renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> {item => {item.name}} ); @@ -227,7 +234,7 @@ export const AsyncGridListVirtualized = () => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); return { @@ -249,7 +256,7 @@ export const AsyncGridListVirtualized = () => { aria-label="async virtualized gridlist" isLoading={list.isLoading} onLoadMore={list.loadMore} - renderEmptyState={() => list.isLoading ? 'Loading spinner' : 'No results found'}> + renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> {item => {item.name}} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 5cd9d4e8fa0..4455af1f1b2 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -14,7 +14,7 @@ import {action} from '@storybook/addon-actions'; import {Collection, DropIndicator, GridLayout, Header, ListBox, ListBoxItem, ListBoxProps, ListBoxSection, ListLayout, Separator, Text, useDragAndDrop, Virtualizer, WaterfallLayout} from 'react-aria-components'; import {Key, useAsyncList, useListData} from 'react-stately'; import {Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; -import {MyListBoxItem} from './utils'; +import {LoadingSpinner, MyListBoxItem} from './utils'; import React, {useMemo} from 'react'; import styles from '../example/index.css'; import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; @@ -431,6 +431,14 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}) { ); } +let renderEmptyState = ({isLoading}) => { + return ( +
+ {isLoading ? : 'No results'} +
+ ); +}; + interface Character { name: string, height: number, @@ -440,10 +448,8 @@ interface Character { const MyListBoxLoaderIndicator = () => { return ( - - - Load more spinner - + + ); }; @@ -457,7 +463,7 @@ export const AsyncListBox = (args) => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -480,7 +486,7 @@ export const AsyncListBox = (args) => { aria-label="async listbox" isLoading={list.isLoading} onLoadMore={list.loadMore} - renderEmptyState={() => list.isLoading ? 'Loading spinner' : 'No results found'}> + renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> {(item: Character) => ( { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -604,7 +610,7 @@ export const AsyncListBoxVirtualized = (args) => { aria-label="async virtualized listbox" isLoading={list.isLoading} onLoadMore={list.loadMore} - renderEmptyState={() => list.isLoading ? 'Loading spinner' : 'No results found'}> + renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> {(item: Character) => ( { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -135,9 +135,10 @@ export const AsyncVirtualizedDynamicSelect = () => { return ( - diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 696c273cd07..ccef76ffb17 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -13,8 +13,8 @@ import {action} from '@storybook/addon-actions'; import {Button, Cell, Checkbox, CheckboxProps, Collection, Column, ColumnProps, ColumnResizer, Dialog, DialogTrigger, DropIndicator, 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, {Suspense, useMemo, useRef, useState} from 'react'; +import {LoadingSpinner, MyMenuItem} from './utils'; +import React, {Suspense, useState} from 'react'; import styles from '../example/index.css'; import {UNSTABLE_TableLoadingIndicator} from '../src/Table'; import {useAsyncList, useListData} from 'react-stately'; @@ -512,10 +512,8 @@ const MyTableLoadingIndicator = ({tableWidth = 400}) => { // These styles will make the load more spinner sticky. A user would know if their table is virtualized and thus could control this styling if they wanted to // TODO: this doesn't work because the virtualizer wrapper around the table body has overflow: hidden. Perhaps could change this by extending the table layout and // making the layoutInfo for the table body have allowOverflow - - - Load more spinner - + + ); }; @@ -602,8 +600,8 @@ export const TableLoadingRowRenderWrapperStory = { function renderEmptyLoader({isLoading, tableWidth = 400}) { - let contents = isLoading ? 'Loading spinner' : 'No results found'; - return
{contents}
; + let contents = isLoading ? : 'No results found'; + return
{contents}
; } const RenderEmptyState = (args: {isLoading: boolean}) => { @@ -657,7 +655,7 @@ const OnLoadMoreTable = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -850,7 +848,7 @@ const OnLoadMoreTableVirtualized = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -906,7 +904,7 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -953,7 +951,12 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { export const OnLoadMoreTableVirtualizedResizeWrapperStory = { render: OnLoadMoreTableVirtualizedResizeWrapper, - name: 'Virtualized Table with async loading, resizable table container wrapper' + name: 'Virtualized Table with async loading, with wrapper around Virtualizer', + parameters: { + description: { + data: 'This table has a ResizableTableContainer wrapper around the Virtualizer. The table itself doesnt have any resizablity, this is simply to test that it still loads/scrolls in this configuration.' + } + } }; interface Launch { diff --git a/packages/react-aria-components/stories/utils.tsx b/packages/react-aria-components/stories/utils.tsx index bc970c4b98e..c277ef1630e 100644 --- a/packages/react-aria-components/stories/utils.tsx +++ b/packages/react-aria-components/stories/utils.tsx @@ -1,5 +1,5 @@ import {classNames} from '@react-spectrum/utils'; -import {ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps} from 'react-aria-components'; +import {ListBoxItem, ListBoxItemProps, MenuItem, MenuItemProps, ProgressBar} from 'react-aria-components'; import React from 'react'; import styles from '../example/index.css'; @@ -29,3 +29,20 @@ export const MyMenuItem = (props: MenuItemProps) => { })} /> ); }; + +export const LoadingSpinner = ({style = {}}) => { + return ( + + + + + + + + + ); +}; From 0b06824b1630bbac162a171a71c2943b0d312a58 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 18 Mar 2025 15:24:00 -0700 Subject: [PATCH 13/90] fix FF load more resizable wrapping container table, make s2 picker load more like v3 --- packages/@react-aria/utils/src/useLoadMore.ts | 14 ++- packages/@react-spectrum/s2/src/Picker.tsx | 104 ++++++++++-------- .../stories/GridList.stories.tsx | 2 +- .../stories/ListBox.stories.tsx | 11 +- .../react-aria-components/test/Table.test.js | 22 ++-- 5 files changed, 87 insertions(+), 66 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index bb8b14bc6b1..f12347da302 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -49,12 +49,15 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject(null); let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { - // Only one entry should exist so this should be ok. Also use "isIntersecting" over an equality check of 0 since it seems like there is cases where + // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where // a intersection ratio of 0 can be reported when isIntersecting is actually true - if (entries[0].isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { - onLoadMore(); - if (collection !== null && lastCollection.current !== null) { - collectionAwaitingUpdate.current = true; + // TODO: firefox seems to gather multiple entries, will need to reproduce in a base repro + for (let entry of entries) { + if (entry.isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + onLoadMore(); + if (collection !== null && lastCollection.current !== null) { + collectionAwaitingUpdate.current = true; + } } } }); @@ -72,7 +75,6 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject {(renderProps) => ( - <> - * {display: none;}')}> - {({defaultChildren}) => { - return ( - - {defaultChildren} - - ); - }} - - {isInvalid && } - {isLoading && loadingCircle} - - {isFocusVisible && isQuiet && } - {isInvalid && !isDisabled && !isQuiet && - // @ts-ignore known limitation detecting functions from the theme -
- } - + )} @@ -562,16 +532,58 @@ export function PickerSection(props: PickerSectionProps): R ); } -interface ChevronProps extends Pick, 'size' | 'isLoading'> {} +interface PickerButtonInnerProps extends Pick, 'size' | 'isLoading' | 'isQuiet'>, Pick, ButtonRenderProps { + loadingCircle: ReactNode +} -function Chevron(props: ChevronProps) { - let {size, isLoading} = props; +function PickerButtonInner(props: PickerButtonInnerProps) { + let {size, isLoading, isQuiet, isInvalid, isDisabled, isFocusVisible, isOpen, loadingCircle} = props; let state = useContext(SelectStateContext); // If it is the initial load, the collection either hasn't been formed or only has the loader so apply the disabled style let isInitialLoad = (state?.collection.size == null || state?.collection.size <= 1) && isLoading; + return ( - + <> + * {display: none;}')}> + {({defaultChildren}) => { + return ( + + {defaultChildren} + + ); + }} + + {isInvalid && } + {isInitialLoad && !isOpen && loadingCircle} + + {isFocusVisible && isQuiet && } + {isInvalid && !isDisabled && !isQuiet && + // @ts-ignore known limitation detecting functions from the theme +
+ } + ); } diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index d4e5cca10bc..32d627de7bb 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -12,11 +12,11 @@ import {Button, Checkbox, CheckboxProps, Collection, DropIndicator, GridLayout, GridList, GridListItem, GridListItemProps, ListLayout, Size, Tag, TagGroup, TagList, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; +import {LoadingSpinner} from './utils'; import React from 'react'; import styles from '../example/index.css'; import {UNSTABLE_GridListLoadingIndicator} from '../src/GridList'; import {useAsyncList, useListData} from 'react-stately'; -import { LoadingSpinner } from './utils'; export default { title: 'React Aria Components' diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 4455af1f1b2..71935e290a6 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -249,11 +249,10 @@ export function VirtualizedListBox({variableHeight}) { return ( + })}> {section => ( @@ -478,7 +477,7 @@ export const AsyncListBox = (args) => { { { let rows = getAllByRole('row'); expect(rows).toHaveLength(6); let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); let cell = within(loader).getByRole('rowheader'); expect(cell).toHaveAttribute('colspan', '3'); + + let spinner = within(cell).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); }); it('should not focus the load more row when using ArrowDown', async () => { @@ -1632,7 +1634,8 @@ describe('Table', () => { let rows = getAllByRole('row'); let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1654,7 +1657,8 @@ describe('Table', () => { let rows = getAllByRole('row'); let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1671,7 +1675,8 @@ describe('Table', () => { let rows = getAllByRole('row'); let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1710,7 +1715,8 @@ describe('Table', () => { expect(rows).toHaveLength(2); expect(body).toHaveAttribute('data-empty', 'true'); - expect(loader).toHaveTextContent('Loading spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); rerender(); @@ -1727,7 +1733,8 @@ describe('Table', () => { let rows = getAllByRole('row'); expect(rows).toHaveLength(4); let loader = rows[3]; - expect(loader).toHaveTextContent('Load more spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); let selectAll = getAllByRole('checkbox')[0]; expect(selectAll).toHaveAttribute('aria-label', 'Select All'); @@ -1747,7 +1754,8 @@ describe('Table', () => { expect(rows).toHaveLength(4); expect(rows[1]).toHaveTextContent('Adobe Photoshop'); let loader = rows[3]; - expect(loader).toHaveTextContent('Load more spinner'); + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); let dragButton = getAllByRole('button')[0]; expect(dragButton).toHaveAttribute('aria-label', 'Drag Adobe Photoshop'); From da166f29a956cba5343dacdf9a7ec29ed4e456fe Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 18 Mar 2025 16:51:52 -0700 Subject: [PATCH 14/90] update S2 Picker/Combobox so they are described by loading spinner --- packages/@react-spectrum/s2/src/ComboBox.tsx | 15 +++++++++------ packages/@react-spectrum/s2/src/Picker.tsx | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 79fe72c2fbf..009c32b6069 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -44,7 +44,7 @@ import { } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; @@ -56,7 +56,7 @@ import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; import {menu} from './Picker'; -import {mergeRefs, useResizeObserver} from '@react-aria/utils'; +import {mergeRefs, useResizeObserver, useSlotId} from '@react-aria/utils'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {pressScale} from './pressScale'; @@ -367,6 +367,9 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps | null>(null); let [showLoading, setShowLoading] = useState(false); let isLoading = loadingState === 'loading' || loadingState === 'filtering'; + {/* Logic copied from S1 */} + let showFieldSpinner = useMemo(() => showLoading && (isOpen || menuTrigger === 'manual' || loadingState === 'loading'), [showLoading, isOpen, menuTrigger, loadingState]); + let spinnerId = useSlotId([showFieldSpinner]); let inputValue = state?.inputValue; let lastInputValue = useRef(inputValue); @@ -467,18 +470,18 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps {ctx => ( - + )} {isInvalid && } - {/* Logic copied from S1 */} - {showLoading && (isOpen || menuTrigger === 'manual' || loadingState === 'loading') && ( + {showFieldSpinner && ( + aria-label={stringFormatter.format('table.loading')} /> )} From 0ef52efd3061c111a7544eac9dfd4c26e6b682bd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 19 Mar 2025 11:43:15 -0700 Subject: [PATCH 15/90] fix Talkback and NVDA announcements for loading spinner --- packages/react-aria-components/src/GridList.tsx | 5 +---- packages/react-aria-components/src/ListBox.tsx | 8 ++++++-- packages/react-aria-components/src/Table.tsx | 2 +- .../react-aria-components/stories/ComboBox.stories.tsx | 2 -- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 96f114ebd39..a2b780836a1 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -512,7 +512,6 @@ export interface GridListLoadingIndicatorProps extends StyleProps { } export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingIndicatorProps, ref: ForwardedRef, item: Node) { - let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let renderProps = useRenderProps({ @@ -527,13 +526,11 @@ export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', f
{renderProps.children} diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 0b5628b61d4..2e157df0dac 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -501,10 +501,14 @@ export const UNSTABLE_ListBoxLoadingIndicator = createLeafComponent('loader', fu defaultClassName: 'react-aria-ListBoxLoadingIndicator', values: null }); - let optionProps = {}; + + let optionProps = { + // For Android talkback + tabIndex: -1 + }; if (isVirtualized) { - optionProps['aria-posinset'] = state.collection.size + 1; + optionProps['aria-posinset'] = item.index + 1; optionProps['aria-setsize'] = state.collection.size; } diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index bca1c5f384f..40b341dac5b 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1385,7 +1385,7 @@ export const UNSTABLE_TableLoadingIndicator = createLeafComponent('loader', func let style = {}; if (isVirtualized) { - rowProps['aria-rowindex'] = state.collection.headerRows.length + state.collection.size ; + rowProps['aria-rowindex'] = item.index + 1 + state.collection.headerRows.length; rowHeaderProps['aria-colspan'] = numColumns; style = {display: 'contents'}; } else { diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index ed5245e80ae..47e81664fed 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -336,8 +336,6 @@ export const AsyncVirtualizedCollectionRenderCombobox = () => { {item.name} )} - {/* TODO: loading indicator and/or renderEmpty? The spinner shows at weird times and flickers if - no delay is added, might be nice to support loadingState */} {list.isLoading && } From 76dc08ab805eeb2233c03a452f02511b74be0370 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 9 Apr 2025 14:07:26 -0700 Subject: [PATCH 16/90] clean up some todos --- packages/@react-aria/utils/src/useLoadMore.ts | 1 - packages/react-aria-components/src/ComboBox.tsx | 1 - packages/react-aria-components/src/GridList.tsx | 1 - packages/react-aria-components/src/ListBox.tsx | 5 ++--- packages/react-aria-components/src/Select.tsx | 1 - 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index f12347da302..3854d3b1714 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -51,7 +51,6 @@ export function useLoadMore(props: LoadMoreProps, ref: RefObject { // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where // a intersection ratio of 0 can be reported when isIntersecting is actually true - // TODO: firefox seems to gather multiple entries, will need to reproduce in a base repro for (let entry of entries) { if (entry.isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { onLoadMore(); diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 5db5b454473..bd09d39e90e 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -49,7 +49,6 @@ export interface ComboBoxRenderProps { */ isRequired: boolean, // TODO: do we want loadingState for RAC Combobox or just S2 - // TODO: move types somewhere common later /** * Whether the combobox is currently loading items. * @selector [data-loading] diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 9325236d9b3..1e4381fbeec 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -528,7 +528,6 @@ export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', f return (
({state: inputState, props, listBoxRef}: // dropdown opens but the current state gives the user more freedom as to whether they would like to pre-fetch or not useLoadMore(memoedLoadMoreProps, listBoxRef); - // TODO: add loading indicator to ListBox so user can render that when loading. Think about if completely empty state - // do we leave it up to the user to setup the two states for empty and empty + loading? Do we add a data attibute/prop/renderprop to ListBox - // for isLoading + // TODO: Think about if completely empty state. Do we leave it up to the user to setup the two states for empty and empty + loading? + // Do we add a data attibute/prop/renderprop to ListBox for isLoading return (
Date: Fri, 11 Apr 2025 16:22:09 -0700 Subject: [PATCH 17/90] set overflow to visible on ListLayout --- packages/@react-stately/layout/src/ListLayout.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..ae0e85447e9 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -301,6 +301,7 @@ export class ListLayout exte let layoutNode = this.buildNode(node, x, y); layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; this.layoutNodes.set(node.key, layoutNode); return layoutNode; } @@ -315,6 +316,8 @@ export class ListLayout exte return this.buildSectionHeader(node, x, y); case 'loader': return this.buildLoader(node, x, y); + case 'separator': + return this.buildLoader(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } From 41a28cbe2841a96c8f7d386be7349440a440ae51 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 14 Apr 2025 11:35:01 -0700 Subject: [PATCH 18/90] add useLoadMoreSentinel instead of changing useLoadMore revert v3 components to still use old useLoadMore --- packages/@react-aria/utils/src/index.ts | 1 + packages/@react-aria/utils/src/useLoadMore.ts | 89 +++++++++---------- .../utils/src/useLoadMoreSentinel.ts | 89 +++++++++++++++++++ .../virtualizer/src/ScrollView.tsx | 13 +-- .../virtualizer/src/Virtualizer.tsx | 26 +++--- .../card/test/CardView.test.js | 9 +- .../combobox/test/ComboBox.test.js | 6 +- .../listbox/test/ListBox.test.js | 14 +-- .../table/src/TableViewBase.tsx | 5 +- .../@react-spectrum/table/test/TableTests.js | 20 ++--- .../react-aria-components/src/GridList.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 5 +- packages/react-aria-components/src/Select.tsx | 3 - packages/react-aria-components/src/Table.tsx | 4 +- 14 files changed, 167 insertions(+), 121 deletions(-) create mode 100644 packages/@react-aria/utils/src/useLoadMoreSentinel.ts diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 9db58b2ee24..390606bf24c 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -45,6 +45,7 @@ export {useEffectEvent} from './useEffectEvent'; export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; +export {useLoadMoreSentinel} from './useLoadMoreSentinel'; export {inertValue} from './inertValue'; export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; diff --git a/packages/@react-aria/utils/src/useLoadMore.ts b/packages/@react-aria/utils/src/useLoadMore.ts index 3854d3b1714..ae2995af9fa 100644 --- a/packages/@react-aria/utils/src/useLoadMore.ts +++ b/packages/@react-aria/utils/src/useLoadMore.ts @@ -10,15 +10,15 @@ * governing permissions and limitations under the License. */ -import {Collection, Node} from '@react-types/shared'; -import {RefObject, useRef} from 'react'; -import {useEffectEvent} from './useEffectEvent'; +import {RefObject, useCallback, useRef} from 'react'; +import {useEvent} from './useEvent'; + import {useLayoutEffect} from './useLayoutEffect'; export interface LoadMoreProps { /** Whether data is currently being loaded. */ isLoading?: boolean, - /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ onLoadMore?: () => void, /** * The amount of offset from the bottom of your scrollable region that should trigger load more. @@ -28,62 +28,55 @@ export interface LoadMoreProps { * @default 1 */ scrollOffset?: number, - // TODO: this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? - collection?: Collection>, - /** - * A ref to a sentinel element that is positioned at the end of the list's content. The visibility of this senetinel - * with respect to the scrollable region and its offset determines if we should load more. - */ - sentinelRef: RefObject + /** The data currently loaded. */ + items?: any } export function useLoadMore(props: LoadMoreProps, ref: RefObject): void { - let {isLoading, onLoadMore, scrollOffset = 1, collection, sentinelRef} = props; - let lastCollection = useRef(collection); + let {isLoading, onLoadMore, scrollOffset = 1, items} = props; - // If we are in a loading state when this hook is called and a collection is provided, we can assume that the collection will update in the future so we don't - // want to trigger another loadMore until the collection has updated as a result of the load. - // TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking - // this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update - let collectionAwaitingUpdate = useRef(collection && isLoading); - let sentinelObserver = useRef(null); + // Handle scrolling, and call onLoadMore when nearing the bottom. + let isLoadingRef = useRef(isLoading); + let prevProps = useRef(props); + let onScroll = useCallback(() => { + if (ref.current && !isLoadingRef.current && onLoadMore) { + let shouldLoadMore = ref.current.scrollHeight - ref.current.scrollTop - ref.current.clientHeight < ref.current.clientHeight * scrollOffset; - let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { - // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where - // a intersection ratio of 0 can be reported when isIntersecting is actually true - for (let entry of entries) { - if (entry.isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + if (shouldLoadMore) { + isLoadingRef.current = true; onLoadMore(); - if (collection !== null && lastCollection.current !== null) { - collectionAwaitingUpdate.current = true; - } } } - }); + }, [onLoadMore, ref, scrollOffset]); - // TODO: maybe can optimize creating the intersection observer by adding it in a useLayoutEffect but would need said effect to run every render - // so that we can catch when ref.current exists or is modified (maybe return a callbackRef?) and then would need to check if scrollOffset changed. + let lastItems = useRef(items); useLayoutEffect(() => { - if (!ref.current) { - return; + // Only update isLoadingRef if props object actually changed, + // not if a local state change occurred. + if (props !== prevProps.current) { + isLoadingRef.current = isLoading; + prevProps.current = props; } - // Only update this flag if the collection changes when we aren't loading. Guards against cases like the addition of a loading spinner when a load starts or if the user - // temporarily wipes the collection during loading which isn't the collection update via fetch which we are waiting for. - // If collection isn't provided (aka for RSP components which won't provide a collection), flip flag to false so we still trigger onLoadMore - if (collection !== lastCollection.current && !isLoading) { - collectionAwaitingUpdate.current = false; - } - sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: ref.current, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); - if (sentinelRef?.current) { - sentinelObserver.current.observe(sentinelRef.current); + // TODO: Eventually this hook will move back into RAC during which we will accept the collection as a option to this hook. + // We will only load more if the collection has changed after the last load to prevent multiple onLoadMore from being called + // while the data from the last onLoadMore is being processed by RAC collection. + let shouldLoadMore = ref?.current + && !isLoadingRef.current + && onLoadMore + && (!items || items !== lastItems.current) + && ref.current.clientHeight === ref.current.scrollHeight; + + if (shouldLoadMore) { + isLoadingRef.current = true; + onLoadMore?.(); } - lastCollection.current = collection; - return () => { - if (sentinelObserver.current) { - sentinelObserver.current.disconnect(); - } - }; - }, [isLoading, triggerLoadMore, ref, collection, scrollOffset, sentinelRef]); + lastItems.current = items; + }, [isLoading, onLoadMore, props, ref, items]); + + // TODO: maybe this should still just return scroll props? + // Test against case where the ref isn't defined when this is called + // Think this was a problem when trying to attach to the scrollable body of the table in OnLoadMoreTableBodyScroll + useEvent(ref, 'scroll', onScroll); } diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts new file mode 100644 index 00000000000..7c133f660bc --- /dev/null +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -0,0 +1,89 @@ +/* + * 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 {Collection, Node} from '@react-types/shared'; +import {RefObject, useRef} from 'react'; +import {useEffectEvent} from './useEffectEvent'; +import {useLayoutEffect} from './useLayoutEffect'; + +export interface LoadMoreSentinelProps { + /** Whether data is currently being loaded. */ + isLoading?: boolean, + /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ + onLoadMore?: () => void, + /** + * The amount of offset from the bottom of your scrollable region that should trigger load more. + * Uses a percentage value relative to the scroll body's client height. Load more is then triggered + * when your current scroll position's distance from the bottom of the currently loaded list of items is less than + * or equal to the provided value. (e.g. 1 = 100% of the scroll region's height). + * @default 1 + */ + scrollOffset?: number, + // TODO: this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? + collection?: Collection>, + /** + * A ref to a sentinel element that is positioned at the end of the list's content. The visibility of this senetinel + * with respect to the scrollable region and its offset determines if we should load more. + */ + sentinelRef: RefObject +} + +export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { + let {isLoading, onLoadMore, scrollOffset = 1, collection, sentinelRef} = props; + let lastCollection = useRef(collection); + + // If we are in a loading state when this hook is called and a collection is provided, we can assume that the collection will update in the future so we don't + // want to trigger another loadMore until the collection has updated as a result of the load. + // TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking + // this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update + let collectionAwaitingUpdate = useRef(collection && isLoading); + let sentinelObserver = useRef(null); + + let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { + // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where + // a intersection ratio of 0 can be reported when isIntersecting is actually true + for (let entry of entries) { + if (entry.isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + onLoadMore(); + if (collection !== null && lastCollection.current !== null) { + collectionAwaitingUpdate.current = true; + } + } + } + }); + + // TODO: maybe can optimize creating the intersection observer by adding it in a useLayoutEffect but would need said effect to run every render + // so that we can catch when ref.current exists or is modified (maybe return a callbackRef?) and then would need to check if scrollOffset changed. + useLayoutEffect(() => { + if (!ref.current) { + return; + } + + // Only update this flag if the collection changes when we aren't loading. Guards against cases like the addition of a loading spinner when a load starts or if the user + // temporarily wipes the collection during loading which isn't the collection update via fetch which we are waiting for. + // If collection isn't provided (aka for RSP components which won't provide a collection), flip flag to false so we still trigger onLoadMore + if (collection !== lastCollection.current && !isLoading) { + collectionAwaitingUpdate.current = false; + } + sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: ref.current, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); + if (sentinelRef?.current) { + sentinelObserver.current.observe(sentinelRef.current); + } + + lastCollection.current = collection; + return () => { + if (sentinelObserver.current) { + sentinelObserver.current.disconnect(); + } + }; + }, [isLoading, triggerLoadMore, ref, collection, scrollOffset, sentinelRef]); +} diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 4b88a42c177..532dfa59707 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -13,7 +13,6 @@ // @ts-ignore import {flushSync} from 'react-dom'; import {getScrollLeft} from './utils'; -import {inertValue, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import React, { CSSProperties, ForwardedRef, @@ -26,6 +25,7 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; +import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; interface ScrollViewProps extends HTMLAttributes { @@ -35,22 +35,18 @@ interface ScrollViewProps extends HTMLAttributes { innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, - scrollDirection?: 'horizontal' | 'vertical' | 'both', - sentinelRef: React.RefObject + scrollDirection?: 'horizontal' | 'vertical' | 'both' } function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { - let {sentinelRef, ...otherProps} = props; ref = useObjectRef(ref); - let {scrollViewProps, contentProps} = useScrollView(otherProps, ref); + let {scrollViewProps, contentProps} = useScrollView(props, ref); return (
{props.children}
- {/* @ts-ignore - compatibility with React < 19 */} -
); } @@ -62,9 +58,8 @@ interface ScrollViewAria { scrollViewProps: HTMLAttributes, contentProps: HTMLAttributes } -interface ScrollViewOptions extends Omit {} -export function useScrollView(props: ScrollViewOptions, ref: RefObject): ScrollViewAria { +export function useScrollView(props: ScrollViewProps, ref: RefObject): ScrollViewAria { let { contentSize, onVisibleRectChange, diff --git a/packages/@react-aria/virtualizer/src/Virtualizer.tsx b/packages/@react-aria/virtualizer/src/Virtualizer.tsx index 613406d2bc6..0bbd0e56e90 100644 --- a/packages/@react-aria/virtualizer/src/Virtualizer.tsx +++ b/packages/@react-aria/virtualizer/src/Virtualizer.tsx @@ -13,7 +13,7 @@ import {Collection, Key, RefObject} from '@react-types/shared'; import {Layout, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {mergeProps, useLoadMore, useObjectRef} from '@react-aria/utils'; -import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback, useRef} from 'react'; +import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback} from 'react'; import {ScrollView} from './ScrollView'; import {VirtualizerItem} from './VirtualizerItem'; @@ -68,25 +68,21 @@ export const Virtualizer = React.forwardRef(function Virtualizer { state.setVisibleRect(rect); }, [state]); return ( - <> - - {renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)} - - + + {renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)} + ); }) as (props: VirtualizerProps & {ref?: RefObject}) => ReactElement; diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index dcee72c2cbd..9ed6a8df3e9 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Card, CardView, GalleryLayout, GridLayout, WaterfallLayout} from '../'; import {composeStories} from '@storybook/react'; import {Content} from '@react-spectrum/view'; @@ -1186,10 +1186,6 @@ describe('CardView', function () { ${'Grid layout'} | ${GridLayout} ${'Gallery layout'} | ${GalleryLayout} `('$Name CardView should call loadMore when scrolling to the bottom', async function ({layout}) { - let observe = jest.fn(); - let observer = setupIntersectionObserverMock({ - observe - }); let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 3000); let onLoadMore = jest.fn(); let tree = render(); @@ -1200,13 +1196,10 @@ describe('CardView', function () { let cards = tree.getAllByRole('gridcell'); expect(cards).toBeTruthy(); - let sentinel = tree.getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenLastCalledWith(sentinel); let grid = tree.getByRole('grid'); grid.scrollTop = 3000; fireEvent.scroll(grid); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); scrollHeightMock.mockReset(); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 8ed2f3df0e6..4b97d4c810d 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -11,7 +11,7 @@ */ jest.mock('@react-aria/live-announcer'); -import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; @@ -2074,7 +2074,6 @@ describe('ComboBox', function () { jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); }); it('onLoadMore is called on initial open', async () => { - let observer = setupIntersectionObserverMock(); load = jest .fn() .mockImplementationOnce(() => { @@ -2112,7 +2111,6 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); // update size, virtualizer raf kicks in act(() => {jest.advanceTimersToNextTimer();}); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); // onLoadMore queued by previous timer, run it now act(() => {jest.advanceTimersToNextTimer();}); @@ -2139,7 +2137,6 @@ describe('ComboBox', function () { }); it('onLoadMore is not called on when previously opened', async () => { - let observer = setupIntersectionObserverMock(); load = jest .fn() .mockImplementationOnce(() => { @@ -2176,7 +2173,6 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); // update size, virtualizer raf kicks in act(() => {jest.advanceTimersToNextTimer();}); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); // onLoadMore queued by previous timer, run it now act(() => {jest.advanceTimersToNextTimer();}); diff --git a/packages/@react-spectrum/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index c8251cddd3d..92b9c0d03d4 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import Bell from '@spectrum-icons/workflow/Bell'; import {FocusExample} from '../stories/ListBox.stories'; import {Item, ListBox, Section} from '../'; @@ -910,10 +910,6 @@ describe('ListBox', function () { }); it('should fire onLoadMore when scrolling near the bottom', function () { - let observe = jest.fn(); - let observer = setupIntersectionObserverMock({ - observe - }); // Mock clientHeight to match maxHeight prop let maxHeight = 200; jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight); @@ -931,7 +927,7 @@ describe('ListBox', function () { return 48; }); - let {getByRole, getByTestId} = render( + let {getByRole} = render( {item => {item.name}} @@ -944,8 +940,6 @@ describe('ListBox', function () { let listbox = getByRole('listbox'); let options = within(listbox).getAllByRole('option'); expect(options.length).toBe(6); // each row is 48px tall, listbox is 200px. 5 rows fit. + 1/3 overscan - let sentinel = getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenLastCalledWith(sentinel); listbox.scrollTop = 250; fireEvent.scroll(listbox); @@ -959,7 +953,6 @@ describe('ListBox', function () { // there are no more items to load at this height, so loadMore is only called twice listbox.scrollTop = 5000; fireEvent.scroll(listbox); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => jest.runAllTimers()); expect(onLoadMore).toHaveBeenCalledTimes(1); @@ -967,7 +960,6 @@ describe('ListBox', function () { }); it('should fire onLoadMore if there aren\'t enough items to fill the ListBox ', async function () { - let observer = setupIntersectionObserverMock(); // Mock clientHeight to match maxHeight prop let maxHeight = 300; jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight); @@ -1032,11 +1024,9 @@ describe('ListBox', function () { let {getByRole} = render( ); - await act(async () => { jest.runAllTimers(); }); - await act(async () => {observer.instance.triggerCallback([{isIntersecting: true}]);}); let listbox = getByRole('listbox'); let options = within(listbox).getAllByRole('option'); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index cacd7f4c2d6..038742935f2 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -590,8 +590,8 @@ function TableVirtualizer(props: TableVirtualizerProps) { columnWidths: columnResizeState.columnWidths }), [columnResizeState.columnWidths]) }); - let sentinelRef = useRef(null); - useLoadMore({isLoading, onLoadMore, scrollOffset: 1, sentinelRef}, bodyRef); + + useLoadMore({isLoading, onLoadMore, scrollOffset: 1}, bodyRef); let onVisibleRectChange = useCallback((rect: Rect) => { state.setVisibleRect(rect); }, [state]); @@ -700,7 +700,6 @@ function TableVirtualizer(props: TableVirtualizerProps) { }} innerStyle={{overflow: 'visible'}} ref={bodyRef} - sentinelRef={sentinelRef} contentSize={state.contentSize} onVisibleRectChange={onVisibleRectChangeMemo} onScrollStart={state.startScrolling} diff --git a/packages/@react-spectrum/table/test/TableTests.js b/packages/@react-spectrum/table/test/TableTests.js index cd052ef87f4..d9f2065132d 100644 --- a/packages/@react-spectrum/table/test/TableTests.js +++ b/packages/@react-spectrum/table/test/TableTests.js @@ -12,7 +12,7 @@ jest.mock('@react-aria/live-announcer'); jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, setupIntersectionObserverMock, User, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, User, within} from '@react-spectrum/test-utils-internal'; import {ActionButton, Button} from '@react-spectrum/button'; import Add from '@spectrum-icons/workflow/Add'; import {announce} from '@react-aria/live-announcer'; @@ -144,6 +144,12 @@ function pointerEvent(type, opts) { } export let tableTests = () => { + // Temporarily disabling these tests in React 16 because they run into a memory limit and crash. + // TODO: investigate. + if (parseInt(React.version, 10) <= 16) { + return; + } + let offsetWidth, offsetHeight; let user; let testUtilUser = new User({advanceTimer: (time) => jest.advanceTimersByTime(time)}); @@ -4370,11 +4376,6 @@ export let tableTests = () => { }); it('should fire onLoadMore when scrolling near the bottom', function () { - let observe = jest.fn(); - let observer = setupIntersectionObserverMock({ - observe - }); - let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 4100); let items = []; for (let i = 1; i <= 100; i++) { @@ -4400,8 +4401,6 @@ export let tableTests = () => { let body = tree.getAllByRole('rowgroup')[1]; let scrollView = body; - let sentinel = tree.getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenLastCalledWith(sentinel); let rows = within(body).getAllByRole('row'); expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan @@ -4416,7 +4415,6 @@ export let tableTests = () => { scrollView.scrollTop = 2800; fireEvent.scroll(scrollView); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); @@ -4429,7 +4427,6 @@ export let tableTests = () => { let onLoadMore = jest.fn(() => { scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 2000); }); - let observer = setupIntersectionObserverMock(); let TableMock = (props) => ( @@ -4448,8 +4445,7 @@ export let tableTests = () => { ); render(); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); - act(() => {jest.runAllTimers();}); + act(() => jest.runAllTimers()); expect(onLoadMore).toHaveBeenCalledTimes(1); scrollHeightMock.mockReset(); }); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 1e4381fbeec..d737a38f8ed 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -19,7 +19,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, useLoadMore, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {TextContext} from './Text'; @@ -233,7 +233,7 @@ function GridListInner({props, collection, gridListRef: ref}: collection, sentinelRef }), [isLoading, onLoadMore, collection]); - useLoadMore(memoedLoadMoreProps, ref); + useLoadMoreSentinel(memoedLoadMoreProps, ref); return ( diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index e18dd199f7b..477dcc58ea8 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -18,7 +18,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, mergeRefs, useLoadMore, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SeparatorContext} from './Separator'; @@ -237,6 +237,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: let sentinelRef = useRef(null); // TODO: Should scrollOffset for useLoadMore should be configurable by the user + // Yes, make this an option on the sentinel let memoedLoadMoreProps = useMemo(() => ({ isLoading: props.isLoading, onLoadMore: props.onLoadMore, @@ -246,7 +247,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: // TODO: maybe this should be called at the ListBox level and the StandaloneListBox level. At its current place it is only called // when the Listbox in the dropdown is rendered. The benefit to this would be that useLoadMore would trigger a load for the user before the // dropdown opens but the current state gives the user more freedom as to whether they would like to pre-fetch or not - useLoadMore(memoedLoadMoreProps, listBoxRef); + useLoadMoreSentinel(memoedLoadMoreProps, listBoxRef); // TODO: Think about if completely empty state. Do we leave it up to the user to setup the two states for empty and empty + loading? // Do we add a data attibute/prop/renderprop to ListBox for isLoading diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index b94096e4222..5c78f343720 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -170,9 +170,6 @@ function SelectInner({props, selectRef: ref, collection}: Sele delete DOMProps.id; let scrollRef = useRef(null); - // TODO: in select we don't call useLoadMore because we need it to trigger when the listbox opens and has a scrollref - // Perhaps we should still call the first time to populate the list? This is automatically done by useAsyncList so maybe not? - // TODO: Include a slot in the input for a loading spinner? return ( Date: Tue, 15 Apr 2025 14:24:31 -0700 Subject: [PATCH 19/90] refactor useLoadMore and update RAC components load more render a sentinel in the loading indicator always and call useLoadMoreSentinel from there instead. This allows the user to theoretically render as many sentinels as they want with each having its own loading state/logic --- packages/@react-aria/utils/src/index.ts | 2 + .../utils/src/useLoadMoreSentinel.ts | 46 +++-------- packages/@react-spectrum/s2/src/ComboBox.tsx | 7 +- packages/@react-spectrum/s2/src/Picker.tsx | 6 +- packages/@react-spectrum/s2/src/TableView.tsx | 6 +- .../@react-stately/layout/src/ListLayout.ts | 6 +- .../react-aria-components/src/ComboBox.tsx | 26 ++---- .../react-aria-components/src/GridList.tsx | 80 ++++++++++--------- .../react-aria-components/src/ListBox.tsx | 78 +++++++++--------- packages/react-aria-components/src/Select.tsx | 21 ++--- packages/react-aria-components/src/Table.tsx | 78 +++++++++--------- packages/react-aria-components/src/index.ts | 8 +- .../stories/ComboBox.stories.tsx | 71 ++++------------ .../stories/GridList.stories.tsx | 40 ++++++---- .../stories/ListBox.stories.tsx | 67 +++++++++------- .../stories/Select.stories.tsx | 58 ++------------ .../stories/Table.stories.tsx | 43 ++++++---- 17 files changed, 273 insertions(+), 370 deletions(-) diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 390606bf24c..a4370cea6de 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -51,3 +51,5 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; export {useEnterAnimation, useExitAnimation} from './animation'; export {isFocusable, isTabbable} from './isFocusable'; + +export type {LoadMoreSentinelProps} from './useLoadMoreSentinel'; diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 7c133f660bc..59c4eaa4038 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {Collection, Node} from '@react-types/shared'; +import {getScrollParent} from './getScrollParent'; import {RefObject, useRef} from 'react'; import {useEffectEvent} from './useEffectEvent'; import {useLayoutEffect} from './useLayoutEffect'; @@ -27,63 +27,35 @@ export interface LoadMoreSentinelProps { * or equal to the provided value. (e.g. 1 = 100% of the scroll region's height). * @default 1 */ - scrollOffset?: number, - // TODO: this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package? - collection?: Collection>, - /** - * A ref to a sentinel element that is positioned at the end of the list's content. The visibility of this senetinel - * with respect to the scrollable region and its offset determines if we should load more. - */ - sentinelRef: RefObject + scrollOffset?: number + // TODO: Maybe include a scrollRef option so the user can provide the scrollParent to compare against instead of having us look it up } export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { - let {isLoading, onLoadMore, scrollOffset = 1, collection, sentinelRef} = props; - let lastCollection = useRef(collection); + let {isLoading, onLoadMore, scrollOffset = 1} = props; - // If we are in a loading state when this hook is called and a collection is provided, we can assume that the collection will update in the future so we don't - // want to trigger another loadMore until the collection has updated as a result of the load. - // TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking - // this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update - let collectionAwaitingUpdate = useRef(collection && isLoading); let sentinelObserver = useRef(null); let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => { // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where // a intersection ratio of 0 can be reported when isIntersecting is actually true for (let entry of entries) { - if (entry.isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) { + if (entry.isIntersecting && !isLoading && onLoadMore) { onLoadMore(); - if (collection !== null && lastCollection.current !== null) { - collectionAwaitingUpdate.current = true; - } } } }); - // TODO: maybe can optimize creating the intersection observer by adding it in a useLayoutEffect but would need said effect to run every render - // so that we can catch when ref.current exists or is modified (maybe return a callbackRef?) and then would need to check if scrollOffset changed. useLayoutEffect(() => { - if (!ref.current) { - return; - } - - // Only update this flag if the collection changes when we aren't loading. Guards against cases like the addition of a loading spinner when a load starts or if the user - // temporarily wipes the collection during loading which isn't the collection update via fetch which we are waiting for. - // If collection isn't provided (aka for RSP components which won't provide a collection), flip flag to false so we still trigger onLoadMore - if (collection !== lastCollection.current && !isLoading) { - collectionAwaitingUpdate.current = false; - } - sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: ref.current, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); - if (sentinelRef?.current) { - sentinelObserver.current.observe(sentinelRef.current); + if (ref.current) { + sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); + sentinelObserver.current.observe(ref.current); } - lastCollection.current = collection; return () => { if (sentinelObserver.current) { sentinelObserver.current.disconnect(); } }; - }, [isLoading, triggerLoadMore, ref, collection, scrollOffset, sentinelRef]); + }, [isLoading, triggerLoadMore, ref, scrollOffset]); } diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 009c32b6069..153c84f3a30 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -26,7 +26,7 @@ import { ListBoxProps, Provider, SectionProps, - UNSTABLE_ListBoxLoadingIndicator + UNSTABLE_ListBoxLoadingSentinel } from 'react-aria-components'; import {baseColor, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; @@ -411,7 +411,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps - + ); if (typeof children === 'function' && items) { @@ -538,6 +538,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps )} items={items} + // TODO: will need to get rid of this and remder the spinner directly isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} className={menu({size})}> {renderer} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index c9e66881c14..3ef55aef3fa 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -29,7 +29,7 @@ import { SelectRenderProps, SelectStateContext, SelectValue, - UNSTABLE_ListBoxLoadingIndicator + UNSTABLE_ListBoxLoadingSentinel } from 'react-aria-components'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; @@ -329,10 +329,10 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick ); let listBoxLoadingCircle = ( - {loadingCircle} - + ); if (typeof children === 'function' && items) { diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 6c2a0609875..6e3868bbe22 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -38,7 +38,7 @@ import { TableBodyRenderProps, TableLayout, TableRenderProps, - UNSTABLE_TableLoadingIndicator, + UNSTABLE_TableLoadingSentinel, useSlottedContext, useTableOptions, Virtualizer @@ -376,13 +376,13 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T let renderer = children; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let loadMoreSpinner = ( - +
-
+ ); // If the user is rendering their rows in dynamic fashion, wrap their render function in Collection so we can inject diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..e6c5f051638 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -29,7 +29,7 @@ export interface ListLayoutOptions { headingHeight?: number, /** The estimated height of a section header, when the height is variable. */ estimatedHeadingHeight?: number, - /** + /** * The fixed height of a loader element in px. This loader is specifically for * "load more" elements rendered when loading more rows at the root level or inside nested row/sections. * @default 48 @@ -184,7 +184,7 @@ export class ListLayout exte } protected isVisible(node: LayoutNode, rect: Rect): boolean { - return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || node.layoutInfo.type === 'header' || this.virtualizer!.isPersistedKey(node.layoutInfo.key); + return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || node.layoutInfo.type === 'header' || node.layoutInfo.type === 'loader' || this.virtualizer!.isPersistedKey(node.layoutInfo.key); } protected shouldInvalidateEverything(invalidationContext: InvalidationContext): boolean { @@ -324,7 +324,7 @@ export class ListLayout exte let rect = new Rect(x, y, this.padding, 0); let layoutInfo = new LayoutInfo('loader', node.key, rect); rect.width = this.virtualizer!.contentSize.width - this.padding - x; - rect.height = this.loaderHeight || this.rowHeight || this.estimatedRowHeight || DEFAULT_HEIGHT; + rect.height = this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT; return { layoutInfo, diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index bd09d39e90e..2f846289e41 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria'; -import {AsyncLoadable, forwardRefType, RefObject} from '@react-types/shared'; import {ButtonContext} from './Button'; import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; @@ -18,6 +17,7 @@ import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps import {FieldErrorContext} from './FieldError'; import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; +import {forwardRefType, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; @@ -47,16 +47,10 @@ export interface ComboBoxRenderProps { * Whether the combobox is required. * @selector [data-required] */ - isRequired: boolean, - // TODO: do we want loadingState for RAC Combobox or just S2 - /** - * Whether the combobox is currently loading items. - * @selector [data-loading] - */ - isLoading?: boolean + isRequired: boolean } -export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, AsyncLoadable { +export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps { /** The filter function used to determine if a option should be included in the combo box list. */ defaultFilter?: (textValue: string, inputValue: string) => boolean, /** @@ -109,9 +103,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: let { name, formValue = 'key', - allowsCustomValue, - isLoading, - onLoadMore + allowsCustomValue } = props; if (allowsCustomValue) { formValue = 'text'; @@ -178,9 +170,8 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: isOpen: state.isOpen, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false, - isLoading: props.isLoading || false - }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired, props.isLoading]); + isRequired: props.isRequired || false + }), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]); let renderProps = useRenderProps({ ...props, @@ -208,7 +199,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: trigger: 'ComboBox', style: {'--trigger-width': menuWidth} as React.CSSProperties }], - [ListBoxContext, {...listBoxProps, onLoadMore, isLoading, ref: listBoxRef}], + [ListBoxContext, {...listBoxProps, ref: listBoxRef}], [ListStateContext, state], [TextContext, { slots: { @@ -228,8 +219,7 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} - data-required={props.isRequired || undefined} - data-loading={props.isLoading || undefined} /> + data-required={props.isRequired || undefined} /> {name && formValue === 'key' && }
); diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index d737a38f8ed..db96c2fa284 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria'; -import {AsyncLoadable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections'; @@ -19,7 +18,8 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {TextContext} from './Text'; @@ -53,15 +53,10 @@ export interface GridListRenderProps { /** * State of the grid list. */ - state: ListState, - /** - * Whether the grid list is currently loading items. - * @selector [data-loading] - */ - isLoading?: boolean + state: ListState } -export interface GridListProps extends Omit, 'children'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, AsyncLoadable { +export interface GridListProps extends Omit, 'children'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps { /** * Whether typeahead navigation is disabled. * @default false @@ -105,7 +100,7 @@ interface GridListInnerProps { } function GridListInner({props, collection, gridListRef: ref}: GridListInnerProps) { - let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack', isLoading, onLoadMore} = props; + let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props; let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext); let state = useListState({ ...props, @@ -204,8 +199,7 @@ function GridListInner({props, collection, gridListRef: ref}: isFocused, isFocusVisible, layout, - state, - isLoading: isLoading || false + state }; let renderProps = useRenderProps({ className: props.className, @@ -226,14 +220,6 @@ function GridListInner({props, collection, gridListRef: ref}:
); } - let sentinelRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading, - onLoadMore, - collection, - sentinelRef - }), [isLoading, onLoadMore, collection]); - useLoadMoreSentinel(memoedLoadMoreProps, ref); return ( @@ -248,8 +234,7 @@ function GridListInner({props, collection, gridListRef: ref}: data-empty={state.collection.size === 0 || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} - data-layout={layout} - data-loading={isLoading || undefined}> + data-layout={layout}> ({props, collection, gridListRef: ref}: scrollRef={ref} persistedKeys={useDndPersistedKeys(selectionManager, dragAndDropHooks, dropState)} renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> - {/* @ts-ignore - compatibility with React < 19 */} -
{emptyState} {dragPreview} @@ -510,15 +493,27 @@ function RootDropIndicator() { ); } -export interface GridListLoadingIndicatorProps extends StyleProps { +export interface GridListLoadingIndicatorProps extends LoadMoreSentinelProps, StyleProps { children?: ReactNode } -export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingIndicatorProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingIndicatorProps, ref: ForwardedRef, item: Node) { + let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); + let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; + + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo(() => ({ + isLoading, + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ - ...props, + ...otherProps, id: undefined, children: item.rendered, defaultClassName: 'react-aria-GridListLoadingIndicator', @@ -526,17 +521,26 @@ export const UNSTABLE_GridListLoadingIndicator = createLeafComponent('loader', f }); return ( -
-
- {renderProps.children} + <> + {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
-
+ {isLoading && renderProps.children && ( +
+
+ {renderProps.children} +
+
+ )} + ); }); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 477dcc58ea8..b592bed3570 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -11,14 +11,14 @@ */ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; -import {AsyncLoadable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, LoadMoreSentinelProps, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SeparatorContext} from './Separator'; @@ -54,15 +54,10 @@ export interface ListBoxRenderProps { /** * State of the listbox. */ - state: ListState, - /** - * Whether the listbox is currently loading items. - * @selector [data-loading] - */ - isLoading?: boolean + state: ListState } -export interface ListBoxProps extends Omit, 'children' | 'label'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps, AsyncLoadable { +export interface ListBoxProps extends Omit, 'children' | 'label'>, CollectionProps, StyleRenderProps, SlotProps, ScrollableProps { /** How multiple selection should behave in the collection. */ selectionBehavior?: SelectionBehavior, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the ListBox. */ @@ -213,8 +208,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: isFocused, isFocusVisible, layout: props.layout || 'stack', - state, - isLoading: props.isLoading || false + state }; let renderProps = useRenderProps({ className: props.className, @@ -235,20 +229,6 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ); } - let sentinelRef = useRef(null); - // TODO: Should scrollOffset for useLoadMore should be configurable by the user - // Yes, make this an option on the sentinel - let memoedLoadMoreProps = useMemo(() => ({ - isLoading: props.isLoading, - onLoadMore: props.onLoadMore, - collection, - sentinelRef - }), [props.isLoading, props.onLoadMore, collection]); - // TODO: maybe this should be called at the ListBox level and the StandaloneListBox level. At its current place it is only called - // when the Listbox in the dropdown is rendered. The benefit to this would be that useLoadMore would trigger a load for the user before the - // dropdown opens but the current state gives the user more freedom as to whether they would like to pre-fetch or not - useLoadMoreSentinel(memoedLoadMoreProps, listBoxRef); - // TODO: Think about if completely empty state. Do we leave it up to the user to setup the two states for empty and empty + loading? // Do we add a data attibute/prop/renderprop to ListBox for isLoading return ( @@ -265,8 +245,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-layout={props.layout || 'stack'} - data-orientation={props.orientation || 'vertical'} - data-loading={props.isLoading || undefined}> + data-orientation={props.orientation || 'vertical'}> ({state: inputState, props, listBoxRef}: scrollRef={listBoxRef} persistedKeys={useDndPersistedKeys(selectionManager, dragAndDropHooks, dropState)} renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> - {/* @ts-ignore - compatibility with React < 19 */} -
{emptyState} {dragPreview} @@ -489,16 +466,26 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); -export interface ListBoxLoadingIndicatorProps extends StyleProps { +export interface ListBoxLoadingSentinelProps extends LoadMoreSentinelProps, StyleProps { children?: ReactNode } -export const UNSTABLE_ListBoxLoadingIndicator = createLeafComponent('loader', function ListBoxLoadingIndicator(props: ListBoxLoadingIndicatorProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', function ListBoxLoadingIndicator(props: ListBoxLoadingSentinelProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); + let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo(() => ({ + isLoading, + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ - ...props, + ...otherProps, id: undefined, children: item.rendered, defaultClassName: 'react-aria-ListBoxLoadingIndicator', @@ -516,14 +503,23 @@ export const UNSTABLE_ListBoxLoadingIndicator = createLeafComponent('loader', fu } return ( -
- {renderProps.children} -
+ <> + {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && renderProps.children && ( +
+ {renderProps.children} +
+ )} + ); }); diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index 5c78f343720..35613d14b5e 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -11,7 +11,6 @@ */ import {AriaSelectProps, HiddenSelect, useFocusRing, useLocalizedStringFormatter, useSelect} from 'react-aria'; -import {AsyncLoadable, forwardRefType} from '@react-types/shared'; import {ButtonContext} from './Button'; import {Collection, Node, SelectState, useSelectState} from 'react-stately'; import {CollectionBuilder} from '@react-aria/collections'; @@ -19,6 +18,7 @@ import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps import {FieldErrorContext} from './FieldError'; import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; +import {forwardRefType} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ItemRenderProps} from './Collection'; @@ -59,15 +59,10 @@ export interface SelectRenderProps { * Whether the select is required. * @selector [data-required] */ - isRequired: boolean, - /** - * Whether the select is currently loading items. - * @selector [data-loading] - */ - isLoading?: boolean + isRequired: boolean } -export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps, AsyncLoadable {} +export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps {} export const SelectContext = createContext, HTMLDivElement>>(null); export const SelectStateContext = createContext | null>(null); @@ -156,9 +151,8 @@ function SelectInner({props, selectRef: ref, collection}: Sele isFocusVisible, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, - isRequired: props.isRequired || false, - isLoading: props.isLoading || false - }), [state.isOpen, state.isFocused, isFocusVisible, props.isDisabled, validation.isInvalid, props.isRequired, props.isLoading]); + isRequired: props.isRequired || false + }), [state.isOpen, state.isFocused, isFocusVisible, props.isDisabled, validation.isInvalid, props.isRequired]); let renderProps = useRenderProps({ ...props, @@ -188,7 +182,7 @@ function SelectInner({props, selectRef: ref, collection}: Sele style: {'--trigger-width': buttonWidth} as React.CSSProperties, 'aria-labelledby': menuProps['aria-labelledby'] }], - [ListBoxContext, {...menuProps, isLoading: props.isLoading, onLoadMore: props.onLoadMore, ref: scrollRef}], + [ListBoxContext, {...menuProps, ref: scrollRef}], [ListStateContext, state], [TextContext, { slots: { @@ -209,8 +203,7 @@ function SelectInner({props, selectRef: ref, collection}: Sele data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} - data-required={props.isRequired || undefined} - data-loading={props.isLoading || undefined} /> + data-required={props.isRequired || undefined} /> , - /** - * Whether the table is currently loading items. - * @selector [data-loading] - */ - isLoading?: boolean + state: TableState } -export interface TableProps extends Omit, 'children'>, StyleRenderProps, SlotProps, AriaLabelingProps, ScrollableProps, AsyncLoadable { +export interface TableProps extends Omit, 'children'>, StyleRenderProps, SlotProps, AriaLabelingProps, ScrollableProps { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children?: ReactNode, /** @@ -382,7 +377,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl }); let {isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate, CollectionRoot} = useContext(CollectionRendererContext); - let {dragAndDropHooks, isLoading, onLoadMore} = props; + let {dragAndDropHooks} = props; let {gridProps} = useTable({ ...props, layoutDelegate, @@ -457,8 +452,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isDropTarget: isRootDropTarget, isFocused, isFocusVisible, - state, - isLoading: isLoading || false + state } }); @@ -481,18 +475,6 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl let ElementType = useElementType('table'); - let sentinelRef = useRef(null); - let memoedLoadMoreProps = useMemo(() => ({ - isLoading, - onLoadMore, - collection, - sentinelRef - }), [isLoading, onLoadMore, collection]); - useLoadMoreSentinel(memoedLoadMoreProps, tableContainerContext?.scrollRef ?? ref); - // TODO: double check this, can't render the sentinel as a div for non-virtualized cases, theoretically - // the inert should hide this from the accessbility tree anyways - let TBody = useElementType('tbody'); - return ( + data-focus-visible={isFocusVisible || undefined}> - {/* @ts-ignore - compatibility with React < 19 */} -
{dragPreview} @@ -1367,17 +1346,28 @@ function RootDropIndicator() { ); } -export interface TableLoadingIndicatorProps extends StyleProps { +export interface TableLoadingIndicatorProps extends LoadMoreSentinelProps, StyleProps { children?: ReactNode } -export const UNSTABLE_TableLoadingIndicator = createLeafComponent('loader', function TableLoadingIndicator(props: TableLoadingIndicatorProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', function TableLoadingIndicator(props: TableLoadingIndicatorProps, ref: ForwardedRef, item: Node) { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); + let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; let numColumns = state.collection.columns.length; + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo(() => ({ + isLoading, + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + let renderProps = useRenderProps({ - ...props, + ...otherProps, id: undefined, children: item.rendered, defaultClassName: 'react-aria-TableLoadingIndicator', @@ -1399,15 +1389,25 @@ export const UNSTABLE_TableLoadingIndicator = createLeafComponent('loader', func return ( <> - - + {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* TODO weird structure? Renders a extra row but we hide via inert so maybe ok? */} + {/* @ts-ignore - compatibility with React < 19 */} + + + {isLoading && renderProps.children && ( + + + + )} + {/* TODO should I also render the empty state render here or do I change all the isEmpty: collection.size === 0 checks + to specifically filter out the loading sentinels?*/} ); }); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 5f6bfdaca29..c778cf6522e 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -39,7 +39,7 @@ export {DropZone, DropZoneContext} from './DropZone'; export {FieldError, FieldErrorContext} from './FieldError'; export {FileTrigger} from './FileTrigger'; export {Form, FormContext} from './Form'; -export {UNSTABLE_GridListLoadingIndicator, GridList, GridListItem, GridListContext} from './GridList'; +export {UNSTABLE_GridListLoadingSentinel, GridList, GridListItem, GridListContext} from './GridList'; export {Group, GroupContext} from './Group'; export {Header, HeaderContext} from './Header'; export {Heading} from './Heading'; @@ -49,7 +49,7 @@ export {Collection, createLeafComponent as UNSTABLE_createLeafComponent, createB export {Keyboard, KeyboardContext} from './Keyboard'; export {Label, LabelContext} from './Label'; export {Link, LinkContext} from './Link'; -export {UNSTABLE_ListBoxLoadingIndicator, ListBox, ListBoxItem, ListBoxSection, ListBoxContext, ListStateContext} from './ListBox'; +export {UNSTABLE_ListBoxLoadingSentinel, ListBox, ListBoxItem, ListBoxSection, ListBoxContext, ListStateContext} from './ListBox'; export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger} from './Menu'; export {Meter, MeterContext} from './Meter'; export {Modal, ModalOverlay, ModalContext} from './Modal'; @@ -63,7 +63,7 @@ export {Select, SelectValue, SelectContext, SelectValueContext, SelectStateConte export {Separator, SeparatorContext} from './Separator'; export {Slider, SliderOutput, SliderTrack, SliderThumb, SliderContext, SliderOutputContext, SliderTrackContext, SliderStateContext} from './Slider'; export {Switch, SwitchContext} from './Switch'; -export {UNSTABLE_TableLoadingIndicator, Table, Row, Cell, Column, ColumnResizer, TableHeader, TableBody, TableContext, ResizableTableContainer, useTableOptions, TableStateContext, TableColumnResizeStateContext} from './Table'; +export {UNSTABLE_TableLoadingSentinel, 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'; @@ -113,7 +113,7 @@ export type {InputProps, InputRenderProps} from './Input'; export type {SectionProps, CollectionRenderer} from './Collection'; export type {LabelProps} from './Label'; export type {LinkProps, LinkRenderProps} from './Link'; -export type {ListBoxProps, ListBoxRenderProps, ListBoxItemProps, ListBoxItemRenderProps, ListBoxSectionProps} from './ListBox'; +export type {ListBoxProps, ListBoxRenderProps, ListBoxItemProps, ListBoxItemRenderProps, ListBoxSectionProps, ListBoxLoadingSentinelProps} from './ListBox'; export type {MenuProps, MenuItemProps, MenuItemRenderProps, MenuTriggerProps, SubmenuTriggerProps, MenuSectionProps} from './Menu'; export type {MeterProps, MeterRenderProps} from './Meter'; export type {ModalOverlayProps, ModalRenderProps} from './Modal'; diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 47e81664fed..096772abf64 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -14,7 +14,7 @@ import {Button, Collection, ComboBox, Input, Label, ListBox, ListLayout, Popover import {LoadingSpinner, MyListBoxItem} from './utils'; import React, {useMemo, useState} from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; import {useAsyncList} from 'react-stately'; export default { @@ -238,10 +238,10 @@ export const VirtualizedComboBox = () => { ); }; -let renderEmptyState = ({isLoading}) => { +let renderEmptyState = () => { return (
- {isLoading ? : 'No results'} + No results
); }; @@ -272,7 +272,7 @@ export const AsyncVirtualizedDynamicCombobox = () => { }); return ( - +
@@ -282,9 +282,14 @@ export const AsyncVirtualizedDynamicCombobox = () => {
- - className={styles.menu} renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> - {item => {item.name}} + {/* TODO: one problem with making the loading sentinel always rendered is that the virtualizer will then render it with a div wrapper with a height=row height even when loading is done. Ideally we'd only + render the bare minimum (aka 0 height/width) in this case but the user would need to specify this in their layout */} + + className={styles.menu} renderEmptyState={renderEmptyState}> + + {item => {item.name}} + + @@ -292,54 +297,10 @@ export const AsyncVirtualizedDynamicCombobox = () => { ); }; -const MyListBoxLoaderIndicator = () => { +const MyListBoxLoaderIndicator = (props) => { return ( - - - - ); -}; - -export const AsyncVirtualizedCollectionRenderCombobox = () => { - let list = useAsyncList({ - async load({signal, cursor, filterText}) { - if (cursor) { - cursor = cursor.replace(/^http:\/\//i, 'https://'); - } - - await new Promise(resolve => setTimeout(resolve, 2000)); - let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); - let json = await res.json(); - - return { - items: json.results, - cursor: json.next - }; - } - }); - - return ( - - -
- - {list.isLoading && } - -
- - - className={styles.menu}> - - {item => ( - {item.name} - )} - - {list.isLoading && } - - - -
+ + + ); }; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 6a4372e3d25..178cb3e4bc0 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -15,7 +15,7 @@ import {classNames} from '@react-spectrum/utils'; import {LoadingSpinner} from './utils'; import React from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_GridListLoadingIndicator} from '../src/GridList'; +import {UNSTABLE_GridListLoadingSentinel} from '../src/GridList'; import {useAsyncList, useListData} from 'react-stately'; export default { @@ -192,11 +192,19 @@ interface Character { birth_year: number } -const MyGridListLoaderIndicator = () => { +const MyGridListLoaderIndicator = (props) => { return ( - - - + + + ); }; @@ -222,12 +230,15 @@ export const AsyncGridList = () => { renderEmptyState({isLoading})}> - {item => {item.name}} + // TODO: same deal here, this won't actually render + renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + {item.name} + )} + + ); }; @@ -253,19 +264,18 @@ export const AsyncGridListVirtualized = () => { renderEmptyState({isLoading})}> + renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}> {item => {item.name}} - {list.isLoading && list.items.length > 0 && } + ); diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 63ae3dbdbcc..0a1afa5dee9 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -17,7 +17,7 @@ import {Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer'; import {LoadingSpinner, MyListBoxItem} from './utils'; import React, {useMemo} from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; export default { title: 'React Aria Components' @@ -450,11 +450,21 @@ interface Character { birth_year: number } -const MyListBoxLoaderIndicator = () => { +const MyListBoxLoaderIndicator = (props) => { + let {orientation, ...otherProps} = props; return ( - - - + + + ); }; @@ -470,7 +480,6 @@ export const AsyncListBox = (args) => { await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); - return { items: json.results, cursor: json.next @@ -486,24 +495,24 @@ export const AsyncListBox = (args) => { width: args.orientation === 'horizontal' ? 400 : 200, overflow: 'auto' }} - items={list.items} aria-label="async listbox" - isLoading={list.isLoading} - onLoadMore={list.loadMore} - renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> - {(item: Character) => ( - - {item.name} - - )} + renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + + {item.name} + + )} + + ); }; @@ -586,7 +595,6 @@ export const AsyncListBoxVirtualized = (args) => { await new Promise(resolve => setTimeout(resolve, 2000)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); - return { items: json.results, cursor: json.next @@ -595,8 +603,8 @@ export const AsyncListBoxVirtualized = (args) => { }); let layout = useMemo(() => { - return args.orientation === 'horizontal' ? new HorizontalLayout({rowWidth: 100}) : new ListLayout({rowHeight: 50, padding: 4}); - }, [args.orientation]); + return args.orientation === 'horizontal' ? new HorizontalLayout({rowWidth: 100}) : new ListLayout({rowHeight: 50, padding: 4, loaderHeight: list.isLoading ? 30 : 0}); + }, [args.orientation, list.isLoading]); return ( @@ -612,9 +620,7 @@ export const AsyncListBoxVirtualized = (args) => { display: 'flex' }} aria-label="async virtualized listbox" - isLoading={list.isLoading} - onLoadMore={list.loadMore} - renderEmptyState={({isLoading}) => renderEmptyState({isLoading})}> + renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}> {(item: Character) => ( { )} - {list.isLoading && list.items.length > 0 && } + {/* TODO: figure out why in horizontal oriention, adding this makes every items loaded have index 0, messing up layout */} + ); diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index f94370f9cd7..4456938ca98 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -14,7 +14,7 @@ import {Button, Collection, Label, ListBox, ListLayout, OverlayArrow, Popover, S import {LoadingSpinner, MyListBoxItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_ListBoxLoadingIndicator} from '../src/ListBox'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; import {useAsyncList} from 'react-stately'; export default { @@ -111,54 +111,11 @@ interface Character { birth_year: number } -// TODO: this might just be unrealistic since the user needs to render the loading spinner... Do we help them with that? -// They could technically do something like we do in Table.stories with MyRow where they selectively render the loading spinner -// dynamically but that feels odd -export const AsyncVirtualizedDynamicSelect = () => { - let list = useAsyncList({ - async load({signal, cursor}) { - if (cursor) { - cursor = cursor.replace(/^http:\/\//i, 'https://'); - } - - // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); - let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); - let json = await res.json(); - return { - items: json.results, - cursor: json.next - }; - } - }); - - return ( - - ); -}; - -const MyListBoxLoaderIndicator = () => { +const MyListBoxLoaderIndicator = (props) => { return ( - + - + ); }; @@ -181,7 +138,7 @@ export const AsyncVirtualizedCollectionRenderSelect = () => { }); return ( -
- {renderProps.children} -
+ {renderProps.children} +
+
Name Height @@ -703,7 +706,8 @@ const OnLoadMoreTable = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading', tableWidth: 400})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={isLoading} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -839,7 +843,10 @@ function VirtualizedTableWithEmptyState(args) { Baz renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> {(item) => ( @@ -889,9 +896,10 @@ const OnLoadMoreTableVirtualized = () => { layout={TableLayout} layoutOptions={{ rowHeight: 25, - headingHeight: 25 + headingHeight: 25, + loaderHeight: list.isLoading ? 30 : 0 }}> -
+
Name Height @@ -900,7 +908,8 @@ const OnLoadMoreTableVirtualized = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={isLoading} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -946,9 +955,10 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { layout={TableLayout} layoutOptions={{ rowHeight: 25, - headingHeight: 25 + headingHeight: 25, + loaderHeight: list.isLoading ? 30 : 0 }}> -
+
Name Height @@ -957,7 +967,8 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={isLoading} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( From f8b1745a259ae9e90d197b482c83f6179220b16a Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:51:18 -0700 Subject: [PATCH 20/90] add separator height to list layout --- .../@react-stately/layout/src/ListLayout.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index ae0e85447e9..b402460d04b 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -35,6 +35,10 @@ export interface ListLayoutOptions { * @default 48 */ loaderHeight?: number, + /** + * The fixed height of a separator element in px. + */ + separatorHeight?: number, /** * The thickness of the drop indicator. * @default 2 @@ -74,6 +78,7 @@ export class ListLayout exte protected headingHeight: number | null; protected estimatedHeadingHeight: number | null; protected loaderHeight: number | null; + protected separatorHeight: number | null; protected dropIndicatorThickness: number; protected gap: number; protected padding: number; @@ -98,6 +103,7 @@ export class ListLayout exte this.headingHeight = options.headingHeight ?? null; this.estimatedHeadingHeight = options.estimatedHeadingHeight ?? null; this.loaderHeight = options.loaderHeight ?? null; + this.separatorHeight = options?.separatorHeight ?? null; this.dropIndicatorThickness = options.dropIndicatorThickness || 2; this.gap = options.gap || 0; this.padding = options.padding || 0; @@ -196,6 +202,7 @@ export class ListLayout exte || this.rowHeight !== (options?.rowHeight ?? this.rowHeight) || this.headingHeight !== (options?.headingHeight ?? this.headingHeight) || this.loaderHeight !== (options?.loaderHeight ?? this.loaderHeight) + || this.separatorHeight !== (options?.separatorHeight ?? this.separatorHeight) || this.gap !== (options?.gap ?? this.gap) || this.padding !== (options?.padding ?? this.padding); } @@ -206,6 +213,7 @@ export class ListLayout exte || newOptions.headingHeight !== oldOptions.headingHeight || newOptions.estimatedHeadingHeight !== oldOptions.estimatedHeadingHeight || newOptions.loaderHeight !== oldOptions.loaderHeight + || newOptions.separatorHeight !== oldOptions.separatorHeight || newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness || newOptions.gap !== oldOptions.gap || newOptions.padding !== oldOptions.padding; @@ -228,6 +236,7 @@ export class ListLayout exte this.headingHeight = options?.headingHeight ?? this.headingHeight; this.estimatedHeadingHeight = options?.estimatedHeadingHeight ?? this.estimatedHeadingHeight; this.loaderHeight = options?.loaderHeight ?? this.loaderHeight; + this.separatorHeight = options?.separatorHeight ?? this.separatorHeight; this.dropIndicatorThickness = options?.dropIndicatorThickness ?? this.dropIndicatorThickness; this.gap = options?.gap ?? this.gap; this.padding = options?.padding ?? this.padding; @@ -317,7 +326,7 @@ export class ListLayout exte case 'loader': return this.buildLoader(node, x, y); case 'separator': - return this.buildLoader(node, x, y); + return this.buildSeparator(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } @@ -335,6 +344,19 @@ export class ListLayout exte }; } + protected buildSeparator(node: Node, x: number, y: number): LayoutNode { + let rect = new Rect(x, y, this.padding, 0); + let layoutInfo = new LayoutInfo('separator', node.key, rect); + rect.width = this.virtualizer!.contentSize.width - this.padding - x; + rect.height = this.separatorHeight || this.rowHeight || this.estimatedRowHeight || DEFAULT_HEIGHT; + + return { + layoutInfo, + validRect: rect.intersection(this.requestedRect), + node + }; + } + protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding; @@ -441,6 +463,9 @@ export class ListLayout exte let rect = new Rect(x, y, width, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; + if (node.type === 'separator') { + console.log(layoutInfo.rect); + } return { layoutInfo, children: [], From b272469897bcaadad904d84720ce2848cf83e163 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 15 Apr 2025 16:51:44 -0700 Subject: [PATCH 21/90] change css for picker and combobox --- packages/@react-spectrum/s2/src/ComboBox.tsx | 194 ++++++++++++++++--- packages/@react-spectrum/s2/src/Picker.tsx | 41 ++-- 2 files changed, 198 insertions(+), 37 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 1a755084b22..c1aa7870ca1 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -22,43 +22,42 @@ import { ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, - SectionProps + SectionProps, + Separator, + SeparatorProps, + Virtualizer } from 'react-aria-components'; -import {baseColor, style} from '../style' with {type: 'macro'}; +import {baseColor, edgeToText, focusRing, size, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; +import {centerPadding, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { checkmark, description, - Divider, icon, iconCenterWrapper, - label, - menuitem, - section, - sectionHeader, - sectionHeading + label } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; -import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {divider} from './Divider'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; -import {menu} from './Picker'; import {mergeRefs, useResizeObserver} from '@react-aria/utils'; +import {mergeStyles} from '../style/runtime'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {pressScale} from './pressScale'; import {TextFieldRef} from '@react-types/textfield'; import {useSpectrumContextProps} from './useSpectrumContextProps'; - export interface ComboboxStyleProps { /** * The size of the Combobox. @@ -147,6 +146,130 @@ const iconStyles = style({ } }); +export let listbox = style({ + width: 'full', + boxSizing: 'border-box', + maxHeight: '[inherit]', + overflow: 'auto', + fontFamily: 'sans', + fontSize: 'control' +}); + +export let listboxItem = style({ + ...focusRing(), + boxSizing: 'border-box', + borderRadius: 'control', + font: 'control', + '--labelPadding': { + type: 'paddingTop', + value: centerPadding() + }, + paddingBottom: '--labelPadding', + backgroundColor: { // TODO: revisit color when I have access to dev mode again + default: { + default: 'transparent', + isFocused: baseColor('gray-100').isFocusVisible + } + }, + color: { + default: 'neutral', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + position: 'relative', + // each menu item should take up the entire width, the subgrid will handle within the item + gridColumnStart: 1, + gridColumnEnd: -1, + display: 'grid', + gridTemplateAreas: [ + '. checkmark icon label value keyboard descriptor .', + '. . . description . . . .' + ], + gridTemplateColumns: { + size: { + S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], + M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], + L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], + XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] + } + }, + gridTemplateRows: { + // min-content prevents second row from 'auto'ing to a size larger then 0 when empty + default: 'auto minmax(0, min-content)', + ':has([slot=description])': 'auto auto' + }, + rowGap: { + ':has([slot=description])': space(1) + }, + alignItems: 'baseline', + minHeight: 'control', + height: 'min', + textDecoration: 'none', + cursor: { + default: 'default', + isLink: 'pointer' + }, + transition: 'default' +}, getAllowedOverrides()); + +export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ + color: 'neutral', + boxSizing: 'border-box', + minHeight: 'control', + paddingY: centerPadding(), + display: 'grid', + gridTemplateAreas: [ + '. checkmark icon label value keyboard descriptor .', + '. . . description . . . .' + ], + gridTemplateColumns: { + size: { + S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], + M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], + L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], + XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] + } + }, + rowGap: { + ':has([slot=description])': space(1) + } +}); + +export let listboxHeading = style({ + font: 'ui', + fontWeight: 'bold', + margin: 0, + gridColumnStart: 2, + gridColumnEnd: -2 +}); + +// not sure why edgeToText won't work... +const separator = style({ + display: { + ':is(:last-child > &)': 'none', + default: 'grid' + }, + // marginX: { + // size: { + // S: edgeToText(24), + // M: edgeToText(32), + // L: edgeToText(40), + // XL: edgeToText(48) + // } + // }, + marginX: { + size: { + S: '[calc(24 * 3 / 8)]', + M: '[calc(32 * 3 / 8)]', + L: '[calc(40 * 3 / 8)]', + XL: '[calc(48 * 3 / 8)]' + } + }, + marginY: size(5) // height of the menu separator is 12px, and the divider is 2px +}); + let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); /** @@ -304,19 +427,28 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co })}> - - {children} - + + + {children} + + @@ -326,7 +458,6 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co ); }); - export interface ComboBoxItemProps extends Omit, StyleProps { children: ReactNode } @@ -348,7 +479,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( @@ -383,11 +514,28 @@ export function ComboBoxSection(props: ComboBoxSectionProps return ( <> + {...props}> {props.children} - + ); } + +export function Divider(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL' | undefined}): ReactNode { + let { + size, + ...otherProps + } = props; + + return ( + + ); +} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index ea3a15d66d5..3c80ad31c11 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -23,26 +23,31 @@ import { ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, SectionProps, - SelectValue + SelectValue, + Virtualizer } from 'react-aria-components'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { checkmark, description, - Divider, icon, iconCenterWrapper, label, - menuitem, - section, - sectionHeader, - sectionHeading + section } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; +import { + Divider, + listbox, + listboxHeader, + listboxHeading, + listboxItem +} from './Combobox'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { FieldErrorIcon, @@ -403,19 +408,27 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick })(props)}> - - {children} - + + + {children} + + + @@ -446,7 +459,7 @@ export function PickerItem(props: PickerItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( From 4c5bc69fd1c9ff258182689573441bdd2f7fa2ba Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 15 Apr 2025 17:36:03 -0700 Subject: [PATCH 22/90] fix s2 combobox and picker --- .../utils/src/useLoadMoreSentinel.ts | 1 + packages/@react-spectrum/s2/src/CardView.tsx | 2 + packages/@react-spectrum/s2/src/ComboBox.tsx | 43 +++++++++++-------- packages/@react-spectrum/s2/src/Picker.tsx | 16 ++++--- packages/@react-spectrum/s2/src/TableView.tsx | 1 + .../react-aria-components/src/GridList.tsx | 1 + .../react-aria-components/src/ListBox.tsx | 2 +- packages/react-aria-components/src/Table.tsx | 1 + 8 files changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 59c4eaa4038..7a6dc3587bb 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -15,6 +15,7 @@ import {RefObject, useRef} from 'react'; import {useEffectEvent} from './useEffectEvent'; import {useLayoutEffect} from './useLayoutEffect'; +// TODO pull from AsyncLoadable? export interface LoadMoreSentinelProps { /** Whether data is currently being loaded. */ isLoading?: boolean, diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index 44ead8d687e..0600dff3bd8 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -247,6 +247,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca (!props.renderActionBar ? UNSAFE_className : '') + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, !props.renderActionBar ? styles : undefined)}> + {/* TODO does the sentinel render after the skeletons if provided after here? Will also have to do the same kind of renderer that Picker and Combobox do */} {children} diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 153c84f3a30..fb0f2b058a9 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -28,6 +28,7 @@ import { SectionProps, UNSTABLE_ListBoxLoadingSentinel } from 'react-aria-components'; +import {AsyncLoadable, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { @@ -51,7 +52,6 @@ import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; -import {HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -80,7 +80,8 @@ export interface ComboBoxProps extends SpectrumLabelableProps, HelpTextProps, Pick, 'items'>, - Pick { + Pick, + Pick { /** The contents of the collection. */ children: ReactNode | ((item: T) => ReactNode), /** @@ -314,7 +315,8 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps | null>(null); let [showLoading, setShowLoading] = useState(false); - let isLoading = loadingState === 'loading' || loadingState === 'filtering'; + let isLoadingOrFiltering = loadingState === 'loading' || loadingState === 'filtering'; {/* Logic copied from S1 */} let showFieldSpinner = useMemo(() => showLoading && (isOpen || menuTrigger === 'manual' || loadingState === 'loading'), [showLoading, isOpen, menuTrigger, loadingState]); let spinnerId = useSlotId([showFieldSpinner]); @@ -374,7 +376,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps { - if (isLoading && !showLoading) { + if (isLoadingOrFiltering && !showLoading) { if (timeout.current === null) { timeout.current = setTimeout(() => { setShowLoading(true); @@ -388,7 +390,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps { return () => { @@ -412,13 +414,17 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps - + {loadingState === 'loadingMore' && ( + + )} ); @@ -428,14 +434,14 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps {children} - {loadingState === 'loadingMore' && listBoxLoadingCircle} + {listBoxLoadingCircle} ); } else { renderer = ( <> {children} - {loadingState === 'loadingMore' && listBoxLoadingCircle} + {listBoxLoadingCircle} ); } @@ -532,14 +538,15 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps loadingState != null && ( + // TODO we don't have a way to actually render this if we always render the sentinel since the collection is never techincally empty + // Perhaps we expect the user to change what they render as the child of the loading sentinel? Change what we consider as empty state to + // collection size === 1 + the node is a loader node? + renderEmptyState={() => loadingState != null && loadingState !== 'loadingMore' && ( {loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')} )} items={items} - // TODO: will need to get rid of this and remder the spinner directly - isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} className={menu({size})}> {renderer} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 3ef55aef3fa..c6b72abc9b5 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -53,7 +53,7 @@ import { FieldLabel, HelpText } from './Field'; -import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; +import {AsyncLoadable, FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; @@ -93,7 +93,9 @@ export interface PickerProps extends SpectrumLabelableProps, HelpTextProps, Pick, 'items'>, - Pick { + Pick, + AsyncLoadable + { /** The contents of the collection. */ children: ReactNode | ((item: T) => ReactNode), /** @@ -287,6 +289,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick placeholder = stringFormatter.format('picker.placeholder'), isQuiet, isLoading, + onLoadMore, ...pickerProps } = props; @@ -330,7 +333,9 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick let listBoxLoadingCircle = ( + className={loadingWrapperStyles} + isLoading={isLoading} + onLoadMore={onLoadMore}> {loadingCircle} ); @@ -341,14 +346,14 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick {children} - {isLoading && listBoxLoadingCircle} + {listBoxLoadingCircle} ); } else { renderer = ( <> {children} - {isLoading && listBoxLoadingCircle} + {listBoxLoadingCircle} ); } @@ -451,7 +456,6 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }] ]}> {renderer} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 6e3868bbe22..910c15059da 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -347,6 +347,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re selectedKeys={selectedKeys} defaultSelectedKeys={undefined} onSelectionChange={onSelectionChange} + // TODO remove this in favor of the sentinel, will need to pass loadMore down to the table body isLoading={isLoading} /> diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index db96c2fa284..b024dae01be 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -210,6 +210,7 @@ function GridListInner({props, collection, gridListRef: ref}: let emptyState: ReactNode = null; let emptyStatePropOverrides: HTMLAttributes | null = null; + // TODO: update this to account for if the load more sentinel is provided if (state.collection.size === 0 && props.renderEmptyState) { let content = props.renderEmptyState(renderValues); emptyState = ( diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index b592bed3570..e0ce26401b6 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -218,7 +218,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: }); let emptyState: JSX.Element | null = null; - if (state.collection.size === 0 && props.renderEmptyState) { + if ((state.collection.size === 0 || (state.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!))) && props.renderEmptyState) { emptyState = (
Date: Thu, 17 Apr 2025 11:05:06 -0700 Subject: [PATCH 23/90] fix separator height --- packages/@react-spectrum/s2/package.json | 1 + packages/@react-spectrum/s2/src/ComboBox.tsx | 60 +++++++++++++------ .../s2/stories/ComboBox.stories.tsx | 22 +++++++ .../@react-stately/layout/src/ListLayout.ts | 27 +-------- 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 25c7e00c451..ef24f7d88a4 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -135,6 +135,7 @@ "@react-aria/i18n": "^3.12.8", "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", + "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index c1aa7870ca1..6b77fa5f2dc 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -25,11 +25,12 @@ import { ListLayout, Provider, SectionProps, - Separator, + SeparatorContext, SeparatorProps, + useContextProps, Virtualizer } from 'react-aria-components'; -import {baseColor, edgeToText, focusRing, size, space, style} from '../style' with {type: 'macro'}; +import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { @@ -41,18 +42,19 @@ import { } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; +import {createContext, CSSProperties, ElementType, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; +import {createLeafComponent} from '@react-aria/collections'; import {divider} from './Divider'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; +import {filterDOMProps, mergeRefs, useResizeObserver} from '@react-aria/utils'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; -import {mergeRefs, useResizeObserver} from '@react-aria/utils'; import {mergeStyles} from '../style/runtime'; -import {Placement} from 'react-aria'; +import {Placement, useSeparator} from 'react-aria'; import {PopoverBase} from './Popover'; import {pressScale} from './pressScale'; import {TextFieldRef} from '@react-types/textfield'; @@ -246,10 +248,10 @@ export let listboxHeading = style({ }); // not sure why edgeToText won't work... -const separator = style({ +const separatorWrapper = style({ display: { ':is(:last-child > &)': 'none', - default: 'grid' + default: 'flex' }, // marginX: { // size: { @@ -267,7 +269,8 @@ const separator = style({ XL: '[calc(48 * 3 / 8)]' } }, - marginY: size(5) // height of the menu separator is 12px, and the divider is 2px + height: 12 + // marginY: size(5) // height of the menu separator is 12px, and the divider is 2px }); let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); @@ -440,8 +443,7 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co layoutOptions={{ estimatedRowHeight: 32, padding: 8, - estimatedHeadingHeight: 50, - separatorHeight: 12 + estimatedHeadingHeight: 50 }}> (props: ComboBoxSectionProps } export function Divider(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL' | undefined}): ReactNode { - let { - size, - ...otherProps - } = props; - return ( + }, style({alignSelf: 'center', width: 'full'})))} /> ); } + +const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, SeparatorContext); + + let {elementType, orientation, size, style, className, slot, ...otherProps} = props; + let Element = (elementType as ElementType) || 'hr'; + if (Element === 'hr' && orientation === 'vertical') { + Element = 'div'; + } + + let {separatorProps} = useSeparator({ + ...otherProps, + elementType, + orientation + }); + + return ( +
+ +
+ ); +}); + diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 802b7971840..e1f931163fe 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -99,6 +99,28 @@ export const Dynamic: Story = { }; +function VirtualizedCombobox(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + export const WithIcons: Story = { render: (args) => ( diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index b402460d04b..96dbfb868d5 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -35,10 +35,6 @@ export interface ListLayoutOptions { * @default 48 */ loaderHeight?: number, - /** - * The fixed height of a separator element in px. - */ - separatorHeight?: number, /** * The thickness of the drop indicator. * @default 2 @@ -78,7 +74,6 @@ export class ListLayout exte protected headingHeight: number | null; protected estimatedHeadingHeight: number | null; protected loaderHeight: number | null; - protected separatorHeight: number | null; protected dropIndicatorThickness: number; protected gap: number; protected padding: number; @@ -103,7 +98,6 @@ export class ListLayout exte this.headingHeight = options.headingHeight ?? null; this.estimatedHeadingHeight = options.estimatedHeadingHeight ?? null; this.loaderHeight = options.loaderHeight ?? null; - this.separatorHeight = options?.separatorHeight ?? null; this.dropIndicatorThickness = options.dropIndicatorThickness || 2; this.gap = options.gap || 0; this.padding = options.padding || 0; @@ -202,7 +196,6 @@ export class ListLayout exte || this.rowHeight !== (options?.rowHeight ?? this.rowHeight) || this.headingHeight !== (options?.headingHeight ?? this.headingHeight) || this.loaderHeight !== (options?.loaderHeight ?? this.loaderHeight) - || this.separatorHeight !== (options?.separatorHeight ?? this.separatorHeight) || this.gap !== (options?.gap ?? this.gap) || this.padding !== (options?.padding ?? this.padding); } @@ -213,7 +206,6 @@ export class ListLayout exte || newOptions.headingHeight !== oldOptions.headingHeight || newOptions.estimatedHeadingHeight !== oldOptions.estimatedHeadingHeight || newOptions.loaderHeight !== oldOptions.loaderHeight - || newOptions.separatorHeight !== oldOptions.separatorHeight || newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness || newOptions.gap !== oldOptions.gap || newOptions.padding !== oldOptions.padding; @@ -236,7 +228,6 @@ export class ListLayout exte this.headingHeight = options?.headingHeight ?? this.headingHeight; this.estimatedHeadingHeight = options?.estimatedHeadingHeight ?? this.estimatedHeadingHeight; this.loaderHeight = options?.loaderHeight ?? this.loaderHeight; - this.separatorHeight = options?.separatorHeight ?? this.separatorHeight; this.dropIndicatorThickness = options?.dropIndicatorThickness ?? this.dropIndicatorThickness; this.gap = options?.gap ?? this.gap; this.padding = options?.padding ?? this.padding; @@ -326,7 +317,7 @@ export class ListLayout exte case 'loader': return this.buildLoader(node, x, y); case 'separator': - return this.buildSeparator(node, x, y); + return this.buildItem(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } @@ -344,19 +335,6 @@ export class ListLayout exte }; } - protected buildSeparator(node: Node, x: number, y: number): LayoutNode { - let rect = new Rect(x, y, this.padding, 0); - let layoutInfo = new LayoutInfo('separator', node.key, rect); - rect.width = this.virtualizer!.contentSize.width - this.padding - x; - rect.height = this.separatorHeight || this.rowHeight || this.estimatedRowHeight || DEFAULT_HEIGHT; - - return { - layoutInfo, - validRect: rect.intersection(this.requestedRect), - node - }; - } - protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding; @@ -463,9 +441,6 @@ export class ListLayout exte let rect = new Rect(x, y, width, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; - if (node.type === 'separator') { - console.log(layoutInfo.rect); - } return { layoutInfo, children: [], From fdf423fa6c2338640ce22b87ac8c15f94540ceb6 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:15:15 -0700 Subject: [PATCH 24/90] fix picker's separator --- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 3c80ad31c11..f36c15e5c54 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -506,7 +506,7 @@ export function PickerSection(props: PickerSectionProps): R className={section({size})}> {props.children} - + ); } From 711dff6e69c9542aeac2d5cd04a0dd6f6ab99846 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:17:10 -0700 Subject: [PATCH 25/90] cleanup --- packages/@react-spectrum/s2/src/ComboBox.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 6b77fa5f2dc..a2b57a5d4b2 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -270,7 +270,6 @@ const separatorWrapper = style({ } }, height: 12 - // marginY: size(5) // height of the menu separator is 12px, and the divider is 2px }); let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); From 0938fb992720aaf5333605777e4944f5b6aabac6 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:27:18 -0700 Subject: [PATCH 26/90] update yarn lock --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 1908468e5ff..f63806e474d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7943,6 +7943,7 @@ __metadata: "@react-aria/i18n": "npm:^3.12.8" "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" + "@react-aria/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" From 1eeb7982faafdcabb45d4280196fdcb904199181 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 17 Apr 2025 11:41:51 -0700 Subject: [PATCH 27/90] update S2 CardView and TableView for new loading sentinel refactor --- .../utils/src/useLoadMoreSentinel.ts | 8 +--- packages/@react-spectrum/s2/src/CardView.tsx | 34 +++++++++++++-- packages/@react-spectrum/s2/src/ComboBox.tsx | 2 + packages/@react-spectrum/s2/src/Picker.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 43 +++++++++++-------- .../react-aria-components/src/GridList.tsx | 10 +++-- .../react-aria-components/src/ListBox.tsx | 8 ++-- packages/react-aria-components/src/Table.tsx | 9 ++-- 8 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 7a6dc3587bb..9536190ef38 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -10,17 +10,13 @@ * governing permissions and limitations under the License. */ +import type {AsyncLoadable} from '@react-types/shared'; import {getScrollParent} from './getScrollParent'; import {RefObject, useRef} from 'react'; import {useEffectEvent} from './useEffectEvent'; import {useLayoutEffect} from './useLayoutEffect'; -// TODO pull from AsyncLoadable? -export interface LoadMoreSentinelProps { - /** Whether data is currently being loaded. */ - isLoading?: boolean, - /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ - onLoadMore?: () => void, +export interface LoadMoreSentinelProps extends AsyncLoadable { /** * The amount of offset from the bottom of your scrollable region that should trigger load more. * Uses a percentage value relative to the scroll body's client height. Load more is then triggered diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index 0600dff3bd8..498b80c2ae0 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -12,11 +12,13 @@ import { GridList as AriaGridList, + Collection, ContextValue, GridLayout, GridListItem, GridListProps, Size, + UNSTABLE_GridListLoadingSentinel, Virtualizer, WaterfallLayout } from 'react-aria-components'; @@ -200,6 +202,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca UNSAFE_style, styles, onLoadMore, + items, ...otherProps} = props; let domRef = useDOMRef(ref); let innerRef = useRef(null); @@ -239,6 +242,31 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); + let renderer; + let cardLoadingSentinel = ( + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {cardLoadingSentinel} + + ); + } else { + renderer = ( + <> + {children} + {cardLoadingSentinel} + + ); + } + let cardView = ( @@ -247,9 +275,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca (!props.renderActionBar ? UNSAFE_className : '') + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, !props.renderActionBar ? styles : undefined)}> {/* TODO does the sentinel render after the skeletons if provided after here? Will also have to do the same kind of renderer that Picker and Combobox do */} - {children} + {renderer} diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index fb0f2b058a9..0c10a3ffae7 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -438,6 +438,8 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps ); } else { + // TODO: is there a case where the user might provide items to the Combobox but doesn't provide a function renderer? + // Same case for other components that have this logic (TableView/CardView/Picker) renderer = ( <> {children} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index c6b72abc9b5..f35d95a3605 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -31,6 +31,7 @@ import { SelectValue, UNSTABLE_ListBoxLoadingSentinel } from 'react-aria-components'; +import {AsyncLoadable, FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { @@ -53,7 +54,6 @@ import { FieldLabel, HelpText } from './Field'; -import {AsyncLoadable, FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 910c15059da..02b71fdca9e 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -196,7 +196,9 @@ export class S2TableLayout extends TableLayout { let {children, layoutInfo} = body; // TableLayout's buildCollection always sets the body width to the max width between the header width, but // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty - if (children?.length === 0) { + // TODO: we may want to adjust RAC layouts to do something simlar? Current users of RAC table will probably run into something similar + let isEmptyOrLoading = children?.length === 0 || (children?.length === 1 && children[0].layoutInfo.type === 'loader'); + if (isEmptyOrLoading) { layoutInfo.rect.width = this.virtualizer!.visibleRect.width - 80; } @@ -222,7 +224,8 @@ export class S2TableLayout extends TableLayout { // Needs overflow for sticky loader layoutInfo.allowOverflow = true; // If loading or empty, we'll want the body to be sticky and centered - if (children?.length === 0) { + let isEmptyOrLoading = children?.length === 0 || (children?.length === 1 && children[0].layoutInfo.type === 'loader'); + if (isEmptyOrLoading) { layoutInfo.rect = new Rect(40, 40, this.virtualizer!.visibleRect.width - 80, this.virtualizer!.visibleRect.height - 80); layoutInfo.isSticky = true; } @@ -271,6 +274,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onResizeStart: propsOnResizeStart, onResizeEnd: propsOnResizeEnd, onAction, + onLoadMore, ...otherProps } = props; @@ -293,11 +297,11 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re density, overflowMode, loadingState, + onLoadMore, isInResizeMode, setIsInResizeMode - }), [isQuiet, density, overflowMode, loadingState, isInResizeMode, setIsInResizeMode]); + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode]); - let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let scrollRef = useRef(null); let isCheckboxSelection = props.selectionMode === 'multiple' || props.selectionMode === 'single'; @@ -323,7 +327,8 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re : undefined, // No need for estimated headingHeight since the headers aren't affected by overflow mode: wrap headingHeight: DEFAULT_HEADER_HEIGHT[scale], - loaderHeight: 60 + // If we are not in a loadingMore state, we need to set a height of 0 for the loading sentinel wrapping div so we don't offset the renderEmptyState spinner/empty state + loaderHeight: loadingState === 'loadingMore' ? 60 : 0 }}> + onSelectionChange={onSelectionChange} /> {actionBar} @@ -372,17 +375,21 @@ export interface TableBodyProps extends Omit, 'style' | export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableBody(props: TableBodyProps, ref: DOMRef) { let {items, renderEmptyState, children} = props; let domRef = useDOMRef(ref); - let {loadingState} = useContext(InternalTableContext); + let {loadingState, onLoadMore} = useContext(InternalTableContext); + let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let emptyRender; let renderer = children; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + // TODO: still is offset strangely if loadingMore when there aren't any items in the table, see http://localhost:6006/?path=/story/tableview--empty-state&args=loadingState:loadingMore let loadMoreSpinner = ( - -
- -
+ + {loadingState === 'loadingMore' && ( +
+ +
+ )}
); @@ -396,19 +403,19 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T {children} - {loadingState === 'loadingMore' && loadMoreSpinner} + {loadMoreSpinner} ); } else { renderer = ( <> {children} - {loadingState === 'loadingMore' && loadMoreSpinner} + {loadMoreSpinner} ); } - if (renderEmptyState != null && loadingState !== 'loading') { + if (renderEmptyState != null && !isLoading) { emptyRender = (props: TableBodyRenderProps) => (
{renderEmptyState(props)} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index b024dae01be..b6fb2a9a4c1 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -193,9 +193,11 @@ function GridListInner({props, collection, gridListRef: ref}: } let {focusProps, isFocused, isFocusVisible} = useFocusRing(); + // TODO: What do we think about this check? Ideally we could just query the collection and see if ALL node are loaders and thus have it return that it is empty + let isEmpty = state.collection.size === 0 || (state.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!)?.type === 'loader'); let renderValues = { isDropTarget: isRootDropTarget, - isEmpty: state.collection.size === 0, + isEmpty, isFocused, isFocusVisible, layout, @@ -210,8 +212,8 @@ function GridListInner({props, collection, gridListRef: ref}: let emptyState: ReactNode = null; let emptyStatePropOverrides: HTMLAttributes | null = null; - // TODO: update this to account for if the load more sentinel is provided - if (state.collection.size === 0 && props.renderEmptyState) { + + if (isEmpty && props.renderEmptyState) { let content = props.renderEmptyState(renderValues); emptyState = (
@@ -232,7 +234,7 @@ function GridListInner({props, collection, gridListRef: ref}: slot={props.slot || undefined} onScroll={props.onScroll} data-drop-target={isRootDropTarget || undefined} - data-empty={state.collection.size === 0 || undefined} + data-empty={isEmpty || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-layout={layout}> diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index e0ce26401b6..eff9b14d34d 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -202,9 +202,10 @@ function ListBoxInner({state: inputState, props, listBoxRef}: } let {focusProps, isFocused, isFocusVisible} = useFocusRing(); + let isEmpty = state.collection.size === 0 || (state.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!)?.type === 'loader'); let renderValues = { isDropTarget: isRootDropTarget, - isEmpty: state.collection.size === 0, + isEmpty, isFocused, isFocusVisible, layout: props.layout || 'stack', @@ -218,7 +219,8 @@ function ListBoxInner({state: inputState, props, listBoxRef}: }); let emptyState: JSX.Element | null = null; - if ((state.collection.size === 0 || (state.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!))) && props.renderEmptyState) { + + if (isEmpty && props.renderEmptyState) { emptyState = (
({state: inputState, props, listBoxRef}: slot={props.slot || undefined} onScroll={props.onScroll} data-drop-target={isRootDropTarget || undefined} - data-empty={state.collection.size === 0 || undefined} + data-empty={isEmpty || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined} data-layout={props.layout || 'stack'} diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 9dba086f36f..46cd157a34b 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -931,9 +931,10 @@ export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', )} - {/* TODO should I also render the empty state render here or do I change all the isEmpty: collection.size === 0 checks - to specifically filter out the loading sentinels?*/} ); }); From 5fc224f2e7106a2998040b75acddba535f85d968 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:51:25 -0700 Subject: [PATCH 28/90] fix lint --- packages/@react-spectrum/s2/package.json | 1 + packages/@react-spectrum/s2/src/ComboBox.tsx | 12 ++++++++++-- packages/@react-spectrum/s2/src/Picker.tsx | 4 ++-- .../style-macro-s1/src/spectrum-theme.ts | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index ef24f7d88a4..cfb1dceb6fb 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -146,6 +146,7 @@ "@react-types/shared": "^3.29.0", "@react-types/table": "^3.12.0", "@react-types/textfield": "^3.12.1", + "@spectrum-icons/workflow": "^4.2.20", "csstype": "^3.0.2", "react-aria": "^3.39.0", "react-aria-components": "^1.8.0", diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index a2b57a5d4b2..24caefa248f 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -30,7 +30,7 @@ import { useContextProps, Virtualizer } from 'react-aria-components'; -import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; +import {baseColor, edgeToText, focusRing, size, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { @@ -509,13 +509,21 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { ); } +let sectionStyles = style({ + borderBottomWidth: 2, + paddingBottom: size(5), + height: 'full', + borderBottomStyle: 'solid', + borderColor: 'black' +}) export interface ComboBoxSectionProps extends SectionProps {} export function ComboBoxSection(props: ComboBoxSectionProps): ReactNode { let {size} = useContext(InternalComboboxContext); return ( <> + {...props} + className={sectionStyles}> {props.children} diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index f36c15e5c54..b0d44a96774 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -47,7 +47,7 @@ import { listboxHeader, listboxHeading, listboxItem -} from './Combobox'; +} from './ComboBox'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { FieldErrorIcon, @@ -506,7 +506,7 @@ export function PickerSection(props: PickerSectionProps): R className={section({size})}> {props.children} - + ); } diff --git a/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts b/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts index 2617d0ca528..e866d9a0c6b 100644 --- a/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts +++ b/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts @@ -587,6 +587,7 @@ export const style = createTheme({ borderXWidth: createMappedProperty(value => ({borderInlineWidth: value}), borderWidth), borderYWidth: createMappedProperty(value => ({borderBlockWidth: value}), borderWidth), borderStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const, + borderBottomStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const, strokeWidth: { 0: '0', 1: '1', From 18c65b2b4ca3ecbd33db0ff43ff4f826e9d7e815 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:58:09 -0700 Subject: [PATCH 29/90] remove workflow dependency --- packages/@react-spectrum/s2/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index cfb1dceb6fb..ef24f7d88a4 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -146,7 +146,6 @@ "@react-types/shared": "^3.29.0", "@react-types/table": "^3.12.0", "@react-types/textfield": "^3.12.1", - "@spectrum-icons/workflow": "^4.2.20", "csstype": "^3.0.2", "react-aria": "^3.39.0", "react-aria-components": "^1.8.0", From 85b7edbe547728252d71f3064453c50a3bbe7d75 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:11:55 -0700 Subject: [PATCH 30/90] remove style from s1 theme oops --- packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts b/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts index e866d9a0c6b..2617d0ca528 100644 --- a/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts +++ b/packages/@react-spectrum/style-macro-s1/src/spectrum-theme.ts @@ -587,7 +587,6 @@ export const style = createTheme({ borderXWidth: createMappedProperty(value => ({borderInlineWidth: value}), borderWidth), borderYWidth: createMappedProperty(value => ({borderBlockWidth: value}), borderWidth), borderStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const, - borderBottomStyle: ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none'] as const, strokeWidth: { 0: '0', 1: '1', From 7f5750677aaef8ab70a937b145a16dc8a00fd62d Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:19:29 -0700 Subject: [PATCH 31/90] fix lint --- packages/@react-spectrum/s2/src/ComboBox.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 24caefa248f..a2b57a5d4b2 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -30,7 +30,7 @@ import { useContextProps, Virtualizer } from 'react-aria-components'; -import {baseColor, edgeToText, focusRing, size, space, style} from '../style' with {type: 'macro'}; +import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { @@ -509,21 +509,13 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { ); } -let sectionStyles = style({ - borderBottomWidth: 2, - paddingBottom: size(5), - height: 'full', - borderBottomStyle: 'solid', - borderColor: 'black' -}) export interface ComboBoxSectionProps extends SectionProps {} export function ComboBoxSection(props: ComboBoxSectionProps): ReactNode { let {size} = useContext(InternalComboboxContext); return ( <> + {...props}> {props.children} From d493fc4149ad5abb070f4b74f1de9d95c39e1fe9 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:08:32 -0700 Subject: [PATCH 32/90] picker fixes --- packages/@react-spectrum/s2/src/Picker.tsx | 1 + .../s2/stories/Picker.stories.tsx | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index b0d44a96774..7f7244ea446 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -420,6 +420,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick layout={ListLayout} layoutOptions={{ estimatedRowHeight: 32, + estimatedHeadingHeight: 50, padding: 8 }}> + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + const ValidationRender = (props) => (
From a69a79435733f963dd791f19e27c5dab986d265c Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:31:48 -0700 Subject: [PATCH 33/90] picker cleanup --- packages/@react-spectrum/s2/src/Picker.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 7f7244ea446..9c318361b35 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -37,7 +37,6 @@ import { icon, iconCenterWrapper, label, - section } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; @@ -429,7 +428,6 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick {children}
- @@ -503,8 +501,7 @@ export function PickerSection(props: PickerSectionProps): R return ( <> + {...props}> {props.children} From 4523888d081c7a57e9eb8e7e29e956426df82915 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:39:27 -0700 Subject: [PATCH 34/90] fix lint --- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 9c318361b35..ec7a46c6ca3 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -36,7 +36,7 @@ import { description, icon, iconCenterWrapper, - label, + label } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; From 30520014812c71e99e4f5e24627d815b242fdf29 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:42:46 -0700 Subject: [PATCH 35/90] fix line height in header --- packages/@react-spectrum/s2/src/ComboBox.tsx | 27 +++++++------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index a2b57a5d4b2..dd134a1eaaa 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -221,30 +221,21 @@ export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ boxSizing: 'border-box', minHeight: 'control', paddingY: centerPadding(), - display: 'grid', - gridTemplateAreas: [ - '. checkmark icon label value keyboard descriptor .', - '. . . description . . . .' - ], - gridTemplateColumns: { + marginX: { size: { - S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], - M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], - L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], - XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] + S: '[calc(24 * 3 / 8)]', + M: '[calc(32 * 3 / 8)]', + L: '[calc(40 * 3 / 8)]', + XL: '[calc(48 * 3 / 8)]' } - }, - rowGap: { - ':has([slot=description])': space(1) } }); export let listboxHeading = style({ - font: 'ui', + fontSize: 'ui', fontWeight: 'bold', - margin: 0, - gridColumnStart: 2, - gridColumnEnd: -2 + lineHeight: 'ui', + margin: 0 }); // not sure why edgeToText won't work... @@ -446,7 +437,7 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co }}> + className={listbox({size})}> {children} From 6a98ac53fbce21844f7f0a8384bb81b4ad3af5fe Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 17 Apr 2025 16:48:27 -0700 Subject: [PATCH 36/90] properly persist table loading spinner in virtualized case --- packages/@react-spectrum/s2/src/TableView.tsx | 2 + .../@react-stately/layout/src/TableLayout.ts | 22 ++++- .../stories/Table.stories.tsx | 2 +- .../react-aria-components/test/Table.test.js | 87 ++++++++++++------- 4 files changed, 77 insertions(+), 36 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 02b71fdca9e..e2f8eaeab66 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -381,6 +381,8 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T let renderer = children; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); // TODO: still is offset strangely if loadingMore when there aren't any items in the table, see http://localhost:6006/?path=/story/tableview--empty-state&args=loadingState:loadingMore + // This is because we don't distinguish between loadingMore and loading in the layout, resulting in a different rect being used to build the body. Perhaps can be considered as a user error + // if they pass loadingMore without having any other items in the table. Alternatively, could update the layout so it knows the current loading state. let loadMoreSpinner = ( {loadingState === 'loadingMore' && ( diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 4c820b5b1e9..ee748cace08 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -11,7 +11,7 @@ */ import {DropTarget, ItemDropTarget, Key} from '@react-types/shared'; -import {getChildNodes} from '@react-stately/collections'; +import {getChildNodes, getLastItem} 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'; @@ -251,7 +251,8 @@ export class TableLayout exten let width = 0; let children: LayoutNode[] = []; let rowHeight = this.getEstimatedRowHeight() + this.gap; - for (let node of getChildNodes(collection.body, collection)) { + let childNodes = getChildNodes(collection.body, collection); + for (let node of childNodes) { // Skip rows before the valid rectangle unless they are already cached. if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { y += rowHeight; @@ -269,6 +270,17 @@ export class TableLayout exten if (y > this.requestedRect.maxY) { // Estimate the remaining height for rows that we don't need to layout right now. y += (collection.size - (skipped + children.length)) * rowHeight; + + // Always add the loader sentinel if present. This assumes the loader is the last row in the body, + // will need to refactor when handling multi section loading + let lastNode = getLastItem(childNodes); + if (lastNode?.type === 'loader' && children.at(-1)?.layoutInfo.type !== 'loader') { + let loader = this.buildChild(lastNode, this.padding, y - (this.loaderHeight ?? 0), layoutInfo.key); + loader.layoutInfo.parentKey = layoutInfo.key; + loader.index = collection.size; + width = Math.max(width, loader.layoutInfo.rect.width); + children.push(loader); + } break; } } @@ -442,6 +454,12 @@ export class TableLayout exten this.addVisibleLayoutInfos(res, node.children[idx], rect); } } + + // Always include loading sentinel even when virtualized, we assume it is always the last child for now + let lastRow = node.children.at(-1); + if (lastRow?.layoutInfo.type === 'loader') { + res.push(lastRow.layoutInfo); + } break; } case 'headerrow': diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 3481c3350ba..fc364c8bc09 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -464,7 +464,7 @@ export const DndTable = (props: DndTableProps) => { )} - {props.isLoading && list.items.length > 0 && } +
); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 41037a3d8f1..3d265115f37 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, installPointerEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, triggerLongPress, within} from '@react-spectrum/test-utils-internal'; -import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, Tag, TagGroup, TagList, useDragAndDrop, useTableOptions, Virtualizer} from '../'; +import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, Tag, TagGroup, TagList, UNSTABLE_TableLoadingSentinel, useDragAndDrop, useTableOptions, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; import React, {useMemo, useState} from 'react'; @@ -228,11 +228,9 @@ describe('Table', () => { } let rowGroups = tableTester.rowGroups; - expect(rowGroups).toHaveLength(3); + expect(rowGroups).toHaveLength(2); expect(rowGroups[0]).toHaveAttribute('class', 'react-aria-TableHeader'); expect(rowGroups[1]).toHaveAttribute('class', 'react-aria-TableBody'); - // Sentinel element for loadmore - expect(rowGroups[2]).toHaveAttribute('inert'); for (let cell of tableTester.columns) { expect(cell).toHaveAttribute('class', 'react-aria-Column'); @@ -264,10 +262,9 @@ describe('Table', () => { } let rowGroups = getAllByRole('rowgroup'); - expect(rowGroups).toHaveLength(3); + expect(rowGroups).toHaveLength(2); expect(rowGroups[0]).toHaveAttribute('class', 'table-header'); expect(rowGroups[1]).toHaveAttribute('class', 'table-body'); - expect(rowGroups[2]).toHaveAttribute('inert'); for (let cell of getAllByRole('columnheader')) { expect(cell).toHaveAttribute('class', 'column'); @@ -299,7 +296,7 @@ describe('Table', () => { } let rowGroups = getAllByRole('rowgroup'); - expect(rowGroups).toHaveLength(3); + expect(rowGroups).toHaveLength(2); expect(rowGroups[0]).toHaveAttribute('data-testid', 'table-header'); expect(rowGroups[1]).toHaveAttribute('data-testid', 'table-body'); @@ -1641,21 +1638,37 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows).toHaveLength(6); - let loader = rows[5]; + expect(rows).toHaveLength(7); + let loader = rows[6]; let cell = within(loader).getByRole('rowheader'); expect(cell).toHaveAttribute('colspan', '3'); let spinner = within(cell).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); + + let sentinel = rows[5]; + expect(sentinel).toHaveAttribute('inert', 'true'); + }); + + it('should still render the sentinel, but not render the spinner if it isnt loading', () => { + let {getAllByRole, queryByRole} = render(); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(6); + + let sentinel = rows[5]; + expect(sentinel).toHaveAttribute('inert', 'true'); + + let spinner = queryByRole('progressbar'); + expect(spinner).toBeFalsy(); }); it('should not focus the load more row when using ArrowDown', async () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - let loader = rows[5]; + let loader = rows[6]; let spinner = within(loader).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); @@ -1678,7 +1691,7 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - let loader = rows[5]; + let loader = rows[6]; let spinner = within(loader).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); @@ -1696,7 +1709,7 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - let loader = rows[5]; + let loader = rows[6]; let spinner = within(loader).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); @@ -1753,8 +1766,8 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows).toHaveLength(4); - let loader = rows[3]; + expect(rows).toHaveLength(5); + let loader = rows[4]; let spinner = within(loader).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); @@ -1773,9 +1786,9 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows).toHaveLength(4); + expect(rows).toHaveLength(5); expect(rows[1]).toHaveTextContent('Adobe Photoshop'); - let loader = rows[3]; + let loader = rows[4]; let spinner = within(loader).getByRole('progressbar'); expect(spinner).toHaveAttribute('aria-label', 'loading'); @@ -1825,18 +1838,23 @@ describe('Table', () => { function LoadMoreTable({onLoadMore, isLoading, items}) { return ( - +
Foo Bar - - {(item) => ( - - {item.foo} - {item.bar} - - )} + + + {(item) => ( + + {item.foo} + {item.bar} + + )} + + +
spinner
+
@@ -1861,7 +1879,7 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(0); let sentinel = tree.getByTestId('loadMoreSentinel'); expect(observe).toHaveBeenLastCalledWith(sentinel); - expect(sentinel.nodeName).toBe('TBODY'); + expect(sentinel.nodeName).toBe('TD'); scrollView.scrollTop = 50; fireEvent.scroll(scrollView); @@ -1987,18 +2005,21 @@ describe('Table', () => { function VirtualizedTableLoad() { return ( - +
Foo Bar - - {item => ( - - {item.foo} - {item.bar} - - )} + + + {item => ( + + {item.foo} + {item.bar} + + )} + +
From 6fb2c01fdc33d39a985e2cc8186dda906a507132 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 17 Apr 2025 17:38:08 -0700 Subject: [PATCH 37/90] fix listbox and gridlist persisted sentinel and fix double spinners --- packages/@react-stately/layout/src/ListLayout.ts | 10 ++++++++++ packages/react-aria-components/src/GridList.tsx | 2 +- packages/react-aria-components/src/ListBox.tsx | 2 +- packages/react-aria-components/src/Table.tsx | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index e6c5f051638..6cc626e7025 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -271,6 +271,16 @@ export class ListLayout exte if (node.type === 'item' && y > this.requestedRect.maxY) { y += (collection.size - (nodes.length + skipped)) * rowHeight; + + // Always add the loader sentinel if present. This assumes the loader is the last row in the body, + // will need to refactor when handling multi section loading + let lastNode = collection.getItem(collection.getLastKey()!); + console.log('last node', lastNode); + + if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') { + let loader = this.buildChild(lastNode, this.padding, y - (this.loaderHeight ?? 0), null); + nodes.push(loader); + } break; } } diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index b6fb2a9a4c1..53766cd6968 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -530,7 +530,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu
- {isLoading && renderProps.children && ( + {isLoading && state.collection.size > 1 && renderProps.children && (
- {isLoading && renderProps.children && ( + {isLoading && state.collection.size > 1 && renderProps.children && (
- {isLoading && renderProps.children && ( + {isLoading && state.collection.size > 1 && renderProps.children && ( Date: Thu, 17 Apr 2025 17:39:50 -0700 Subject: [PATCH 38/90] stray console log --- packages/@react-stately/layout/src/ListLayout.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6cc626e7025..bba9256dd13 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -272,11 +272,9 @@ export class ListLayout exte if (node.type === 'item' && y > this.requestedRect.maxY) { y += (collection.size - (nodes.length + skipped)) * rowHeight; - // Always add the loader sentinel if present. This assumes the loader is the last row in the body, + // Always add the loader sentinel if present. This assumes the loader is the last option/row // will need to refactor when handling multi section loading let lastNode = collection.getItem(collection.getLastKey()!); - console.log('last node', lastNode); - if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') { let loader = this.buildChild(lastNode, this.padding, y - (this.loaderHeight ?? 0), null); nodes.push(loader); From d4bec98e37be66ad145d57bd67705f1b32e68c53 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 18 Apr 2025 10:23:11 -0700 Subject: [PATCH 39/90] fix react 19 tests --- .../react-aria-components/test/Table.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index ecf393a92a1..9bdd3e3840b 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1648,7 +1648,7 @@ describe('Table', () => { expect(spinner).toHaveAttribute('aria-label', 'loading'); let sentinel = rows[5]; - expect(sentinel).toHaveAttribute('inert', 'true'); + expect(sentinel).toHaveAttribute('inert'); }); it('should still render the sentinel, but not render the spinner if it isnt loading', () => { @@ -1658,7 +1658,7 @@ describe('Table', () => { expect(rows).toHaveLength(6); let sentinel = rows[5]; - expect(sentinel).toHaveAttribute('inert', 'true'); + expect(sentinel).toHaveAttribute('inert'); let spinner = queryByRole('progressbar'); expect(spinner).toBeFalsy(); @@ -2216,9 +2216,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2228,9 +2228,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2240,9 +2240,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); From 7f062b80b555cdc46f9e713bb5b91a04105a560f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:24:05 -0700 Subject: [PATCH 40/90] fix lint? --- packages/@react-spectrum/s2/src/ComboBox.tsx | 2 +- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index dd134a1eaaa..682834450fe 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -148,7 +148,7 @@ const iconStyles = style({ } }); -export let listbox = style({ +export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ width: 'full', boxSizing: 'border-box', maxHeight: '[inherit]', diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index ec7a46c6ca3..f3bbcf9e1a1 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -424,7 +424,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }}> + className={listbox({size})}> {children} From 561d91482d713f7efbb6e95c9b033634ee6892ed Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 18 Apr 2025 17:13:21 -0700 Subject: [PATCH 41/90] persist sentinel in card layouts --- .../@react-stately/layout/src/GridLayout.ts | 23 ++++++++++++++++--- .../layout/src/WaterfallLayout.ts | 16 ++++++++++--- .../virtualizer/src/Virtualizer.ts | 2 +- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 14c8e0865b4..176e5c1a997 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -120,8 +120,12 @@ export class GridLayout exte let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); this.gap = new Size(horizontalSpacing, minSpace.height); - let rows = Math.ceil(this.virtualizer!.collection.size / numColumns); - let iterator = this.virtualizer!.collection[Symbol.iterator](); + let collection = this.virtualizer!.collection; + // Make sure to set rows to 0 if we performing a first time load or are rendering the empty state so that Virtualizer + // won't try to render its body + let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); + let rows = isEmptyOrLoading ? 0 : Math.ceil(collection.size / numColumns); + let iterator = collection[Symbol.iterator](); let y = rows > 0 ? minSpace.height : 0; let newLayoutInfos = new Map(); let skeleton: Node | null = null; @@ -136,6 +140,11 @@ export class GridLayout exte break; } + // We will add the loader after the skeletons so skip here + if (node.type === 'loader') { + continue; + } + if (node.type === 'skeleton') { skeleton = node; } @@ -177,6 +186,14 @@ export class GridLayout exte } } + // Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out. + let lastNode = collection.getItem(collection.getLastKey()!); + if (lastNode?.type === 'loader') { + let rect = new Rect(horizontalSpacing, y, itemWidth, 0); + let layoutInfo = new LayoutInfo('loader', lastNode.key, rect); + newLayoutInfos.set(lastNode.key, layoutInfo); + } + this.layoutInfos = newLayoutInfos; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); } @@ -192,7 +209,7 @@ export class GridLayout exte getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { let layoutInfos: LayoutInfo[] = []; for (let layoutInfo of this.layoutInfos.values()) { - if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key)) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key) || layoutInfo.type === 'loader') { layoutInfos.push(layoutInfo); } } diff --git a/packages/@react-stately/layout/src/WaterfallLayout.ts b/packages/@react-stately/layout/src/WaterfallLayout.ts index 6e7b91ef421..5b510e0b73b 100644 --- a/packages/@react-stately/layout/src/WaterfallLayout.ts +++ b/packages/@react-stately/layout/src/WaterfallLayout.ts @@ -140,8 +140,9 @@ export class WaterfallLayout { itemSizeChanged ||= opts.invalidationContext.itemSizeChanged || false; layoutOptionsChanged ||= opts.invalidationContext.layoutOptions != null && this._invalidationContext.layoutOptions != null - && opts.invalidationContext.layoutOptions !== this._invalidationContext.layoutOptions + && opts.invalidationContext.layoutOptions !== this._invalidationContext.layoutOptions && this.layout.shouldInvalidateLayoutOptions(opts.invalidationContext.layoutOptions, this._invalidationContext.layoutOptions); needsLayout ||= itemSizeChanged || sizeChanged || offsetChanged || layoutOptionsChanged; } From dc6e80a7cd4da7dcc2b02db1cc6b057f9ba6c267 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 18 Apr 2025 17:22:50 -0700 Subject: [PATCH 42/90] forgot to fix waterfall empty state --- packages/@react-stately/layout/src/WaterfallLayout.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/layout/src/WaterfallLayout.ts b/packages/@react-stately/layout/src/WaterfallLayout.ts index 5b510e0b73b..01704daa3f1 100644 --- a/packages/@react-stately/layout/src/WaterfallLayout.ts +++ b/packages/@react-stately/layout/src/WaterfallLayout.ts @@ -169,8 +169,9 @@ export class WaterfallLayout Date: Mon, 21 Apr 2025 17:16:44 -0700 Subject: [PATCH 43/90] get rid of extranous space when listbox/table loaded all of the available items --- packages/@react-spectrum/s2/src/CardView.tsx | 1 - .../@react-stately/layout/src/ListLayout.ts | 18 ++++++++++++++---- .../@react-stately/layout/src/TableLayout.ts | 12 +++++++++--- .../stories/ComboBox.stories.tsx | 2 +- .../stories/ListBox.stories.tsx | 2 -- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index 498b80c2ae0..dfcc099d1c8 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -290,7 +290,6 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca scrollPaddingBottom: actionBarHeight + options.minSpace.height }} className={renderProps => (!props.renderActionBar ? UNSAFE_className : '') + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, !props.renderActionBar ? styles : undefined)}> - {/* TODO does the sentinel render after the skeletons if provided after here? Will also have to do the same kind of renderer that Picker and Combobox do */} {renderer} diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index bba9256dd13..d4310c54012 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -268,16 +268,26 @@ export class ListLayout exte let layoutNode = this.buildChild(node, this.padding, y, null); y = layoutNode.layoutInfo.rect.maxY + this.gap; nodes.push(layoutNode); - if (node.type === 'item' && y > this.requestedRect.maxY) { - y += (collection.size - (nodes.length + skipped)) * rowHeight; + let itemsAfterRect = collection.size - (nodes.length + skipped); + let lastNode = collection.getItem(collection.getLastKey()!); + if (lastNode?.type === 'loader') { + itemsAfterRect--; + } + + y += itemsAfterRect * rowHeight; // Always add the loader sentinel if present. This assumes the loader is the last option/row // will need to refactor when handling multi section loading - let lastNode = collection.getItem(collection.getLastKey()!); + // TODO: One issue here is that after new items are loaded, the scroll position "resets" so the the bottom of the scroll body is flush with the bottom of the item that + // was just before the loader, presumabally because the loader collapses to 0 height? Bit annoying since + // prior to the changes in this commit it would replace the loader's node with the newly loaded item node, aka preserving the scroll position. + // However that had an issue with adding too much space at the bottom of the list when you exhausted your api's full set of items due to an errornous calculation being made + // Note that this only an issue with virtualized versions of ListBox and TableView if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') { - let loader = this.buildChild(lastNode, this.padding, y - (this.loaderHeight ?? 0), null); + let loader = this.buildChild(lastNode, this.padding, y, null); nodes.push(loader); + y = loader.layoutInfo.rect.maxY; } break; } diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index ee748cace08..8b710b07a04 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -268,18 +268,24 @@ export class TableLayout exten children.push(layoutNode); if (y > this.requestedRect.maxY) { + let rowsAfterRect = collection.size - (children.length + skipped); + let lastNode = getLastItem(childNodes); + if (lastNode?.type === 'loader') { + rowsAfterRect--; + } + // Estimate the remaining height for rows that we don't need to layout right now. - y += (collection.size - (skipped + children.length)) * rowHeight; + y += rowsAfterRect * rowHeight; // Always add the loader sentinel if present. This assumes the loader is the last row in the body, // will need to refactor when handling multi section loading - let lastNode = getLastItem(childNodes); if (lastNode?.type === 'loader' && children.at(-1)?.layoutInfo.type !== 'loader') { - let loader = this.buildChild(lastNode, this.padding, y - (this.loaderHeight ?? 0), layoutInfo.key); + let loader = this.buildChild(lastNode, this.padding, y, layoutInfo.key); loader.layoutInfo.parentKey = layoutInfo.key; loader.index = collection.size; width = Math.max(width, loader.layoutInfo.rect.width); children.push(loader); + y = loader.layoutInfo.rect.maxY; } break; } diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 096772abf64..d5926d33a8b 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -284,7 +284,7 @@ export const AsyncVirtualizedDynamicCombobox = () => { {/* TODO: one problem with making the loading sentinel always rendered is that the virtualizer will then render it with a div wrapper with a height=row height even when loading is done. Ideally we'd only render the bare minimum (aka 0 height/width) in this case but the user would need to specify this in their layout */} - + className={styles.menu} renderEmptyState={renderEmptyState}> {item => {item.name}} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 0a1afa5dee9..23238c60758 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -468,8 +468,6 @@ const MyListBoxLoaderIndicator = (props) => { ); }; -// TODO: this doesn't have load more spinner since user basically needs to use or wrap their ListboxItem renderer so it renders the -// additional loading indicator based on list load state export const AsyncListBox = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { From a9b7ea613e42d9e1175e513ccfdfdb6ae5ce8fc2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 21 Apr 2025 17:33:33 -0700 Subject: [PATCH 44/90] fix empty state for S2 ComboBox and make sure S2 Picker doesnt open when empty --- packages/@react-spectrum/s2/src/ComboBox.tsx | 5 +---- packages/@react-stately/select/src/useSelectState.ts | 3 ++- packages/react-aria-components/src/ListBox.tsx | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 0c10a3ffae7..b883f56f421 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -540,10 +540,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps loadingState != null && loadingState !== 'loadingMore' && ( + renderEmptyState={() => ( {loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')} diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index 3aff970197e..c8a3ac84040 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -70,7 +70,8 @@ export function useSelectState(props: SelectStateOptions): focusStrategy, open(focusStrategy: FocusStrategy | null = null) { // Don't open if the collection is empty. - if (listState.collection.size !== 0) { + let isEmpty = listState.collection.size === 0 || (listState.collection.size === 1 && listState.collection.getItem(listState.collection.getFirstKey()!)?.type === 'loader'); + if (!isEmpty) { setFocusStrategy(focusStrategy); triggerState.open(); } diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 799d4880397..bb20ebc1b07 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -219,7 +219,6 @@ function ListBoxInner({state: inputState, props, listBoxRef}: }); let emptyState: JSX.Element | null = null; - if (isEmpty && props.renderEmptyState) { emptyState = (
Date: Wed, 23 Apr 2025 16:05:35 -0700 Subject: [PATCH 45/90] fix scroll offset issue after loadMore operations finish in virtualized components works for the most part but is problematic if you dont want the loadingRow to appear with performing initial load (aka loadingState = loading). Due to how useLoadMoreSentinel work, we need to reobserve the list when we go from loading to loadingMore, but is isLoading is true the layout willpreserve room for the loading row... --- .../utils/src/useLoadMoreSentinel.ts | 4 + packages/@react-spectrum/s2/src/ComboBox.tsx | 47 +++++++++- packages/@react-spectrum/s2/src/Picker.tsx | 10 +- packages/@react-spectrum/s2/src/TableView.tsx | 10 +- .../@react-stately/layout/src/ListLayout.ts | 2 +- .../stories/ComboBox.stories.tsx | 8 +- .../stories/GridList.stories.tsx | 5 +- .../stories/ListBox.stories.tsx | 91 +++---------------- .../stories/Select.stories.tsx | 7 +- .../stories/Table.stories.tsx | 4 +- 10 files changed, 91 insertions(+), 97 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 9536190ef38..2bb68d852dd 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -45,6 +45,10 @@ export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject useLayoutEffect(() => { if (ref.current) { + // TODO: problem with S2's Table loading spinner. Now that we use the isLoading provided to the sentinel in the layout to adjust the height of the loader, + // we are getting space reserved for the loadMore spinner when doing initial loading and rendering empty state at the same time. We can somewhat fix this by providing isLoading={loadingState === 'loadingMore'} + // which will mean the layout won't reserve space for the loader for initial loads, but that breaks the load more behavior (specifically, auto load more to fill scrollOffset. Scroll to load more seems broken to after initial load). + // We need to tear down and set up a new IntersectionObserver to force a check if the sentinel is "in view", see https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); sentinelObserver.current.observe(ref.current); } diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 710edf4bdb6..b45fd278cc3 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -65,6 +65,7 @@ import {pressScale} from './pressScale'; import {ProgressCircle} from './ProgressCircle'; import {TextFieldRef} from '@react-types/textfield'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface ComboboxStyleProps { @@ -182,6 +183,14 @@ const progressCircleStyles = style({ }); const emptyStateText = style({ + height: { + size: { + S: 24, + M: 32, + L: 40, + XL: 48 + } + }, font: { size: { S: 'ui-sm', @@ -310,6 +319,26 @@ const separatorWrapper = style({ height: 12 }); +// Not from any design, just following the sizing of the existing rows +export const LOADER_ROW_HEIGHTS = { + S: { + medium: 24, + large: 30 + }, + M: { + medium: 32, + large: 40 + }, + L: { + medium: 40, + large: 50 + }, + XL: { + medium: 48, + large: 60 + } +}; + let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); /** @@ -530,7 +559,12 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps {loadingState === 'loadingMore' && ( @@ -564,6 +598,9 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps @@ -659,8 +696,12 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps ( diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 7a477024ee1..07fb8003f69 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -50,7 +50,8 @@ import { listbox, listboxHeader, listboxHeading, - listboxItem + listboxItem, + LOADER_ROW_HEIGHTS } from './ComboBox'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { @@ -74,6 +75,7 @@ import React, {createContext, forwardRef, ReactNode, useContext, useRef, useStat import {useFocusableRef} from '@react-spectrum/utils'; import {useGlobalListeners, useSlotId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export interface PickerStyleProps { @@ -361,6 +363,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick ); } + let scale = useScale(); return ( - {children} + {renderer} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 25793320fb2..2050d7d7e42 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -327,8 +327,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re : undefined, // No need for estimated headingHeight since the headers aren't affected by overflow mode: wrap headingHeight: DEFAULT_HEADER_HEIGHT[scale], - // If we are not in a loadingMore state, we need to set a height of 0 for the loading sentinel wrapping div so we don't offset the renderEmptyState spinner/empty state - loaderHeight: loadingState === 'loadingMore' ? 60 : 0 + loaderHeight: 60 }}> + // TODO: the below is changed in order NOT to have the layout reserve room for the loading more spinner when loadingState === 'loading' since we have renderEmptyState handle that case + // However, this breaks loading more to fill 1.5X pages of content (aka scrolloffset in useLoadMoreSentinel). I don't think we could have + // the TableLoadingSentinel render both the loadingMore spinner case as well as the initial load spinner case since the latter wants to just be centered in the whole + // table + // + {loadingState === 'loadingMore' && (
exte let rect = new Rect(x, y, this.padding, 0); let layoutInfo = new LayoutInfo('loader', node.key, rect); rect.width = this.virtualizer!.contentSize.width - this.padding - x; - rect.height = this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT; + rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; return { layoutInfo, diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index d5926d33a8b..36bdb349ba4 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -282,14 +282,14 @@ export const AsyncVirtualizedDynamicCombobox = () => {
- {/* TODO: one problem with making the loading sentinel always rendered is that the virtualizer will then render it with a div wrapper with a height=row height even when loading is done. Ideally we'd only - render the bare minimum (aka 0 height/width) in this case but the user would need to specify this in their layout */} - + className={styles.menu} renderEmptyState={renderEmptyState}> {item => {item.name}} - +
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 319fc9a35ed..dcbf7d22371 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -285,12 +285,15 @@ export const AsyncGridListVirtualized = () => { } }); + // TODO: this story also exhibits how the layout is reserving height for the loadingMore spinner row when perform initial load + // even though we only want renderEmptyState to be rendering at that time. The story itself will look fine as is however due to how I've absolutedly + // positioned the renderEmptyState loading spinner... return ( endIndex) { - layoutInfos.push(layoutInfo); - } - } - } - - return layoutInfos; - } - - // Provide a LayoutInfo for a specific item. - getLayoutInfo(key: Key): LayoutInfo | null { - let node = this.virtualizer!.collection.getItem(key); - if (!node) { - return null; - } - - let rect = new Rect(node.index * this.rowWidth, 0, this.rowWidth, 100); - return new LayoutInfo(node.type, node.key, rect); - } - - // Provide the total size of all items. - getContentSize(): Size { - let numItems = this.virtualizer!.collection.size; - return new Size(numItems * this.rowWidth, 100); - } -} - export const AsyncListBoxVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { @@ -600,17 +544,19 @@ export const AsyncListBoxVirtualized = (args) => { } }); - let layout = useMemo(() => { - return args.orientation === 'horizontal' ? new HorizontalLayout({rowWidth: 100}) : new ListLayout({rowHeight: 50, padding: 4, loaderHeight: list.isLoading ? 30 : 0}); - }, [args.orientation, list.isLoading]); return ( + layout={ListLayout} + layoutOptions={{ + rowHeight: 50, + padding: 4, + loaderHeight: 30 + }}> { )} - {/* TODO: figure out why in horizontal oriention, adding this makes every items loaded have index 0, messing up layout */} - + ); }; - -AsyncListBoxVirtualized.story = { - args: { - orientation: 'horizontal' - }, - argTypes: { - orientation: { - control: 'radio', - options: ['horizontal', 'vertical'] - } - } -}; diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 4456938ca98..5127dfca452 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -149,7 +149,12 @@ export const AsyncVirtualizedCollectionRenderSelect = () => { - + {item => ( diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index fc364c8bc09..831b86ea233 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -897,7 +897,7 @@ const OnLoadMoreTableVirtualized = () => { layoutOptions={{ rowHeight: 25, headingHeight: 25, - loaderHeight: list.isLoading ? 30 : 0 + loaderHeight: 30 }}> @@ -956,7 +956,7 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { layoutOptions={{ rowHeight: 25, headingHeight: 25, - loaderHeight: list.isLoading ? 30 : 0 + loaderHeight: 30 }}>
From 95beec710d430ae5756fcf9ef48dda3b9cd4b399 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 23 Apr 2025 16:51:05 -0700 Subject: [PATCH 46/90] dont reserve room for the isLoadingMore spinner if performing initial load also fixes RAC examples by properly applying a height to the tablebody if performing initial load --- packages/@react-aria/utils/src/useLoadMoreSentinel.ts | 2 ++ packages/@react-spectrum/s2/src/TableView.tsx | 7 +------ packages/@react-stately/layout/src/ListLayout.ts | 7 ++++++- packages/@react-stately/layout/src/TableLayout.ts | 4 +++- .../react-aria-components/stories/GridList.stories.tsx | 3 --- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 2bb68d852dd..0ffe15a71cd 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -49,6 +49,8 @@ export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject // we are getting space reserved for the loadMore spinner when doing initial loading and rendering empty state at the same time. We can somewhat fix this by providing isLoading={loadingState === 'loadingMore'} // which will mean the layout won't reserve space for the loader for initial loads, but that breaks the load more behavior (specifically, auto load more to fill scrollOffset. Scroll to load more seems broken to after initial load). // We need to tear down and set up a new IntersectionObserver to force a check if the sentinel is "in view", see https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 + // I've actually fixed this via a ListLayout change (TableLayout extends this) where I check "collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader')" + // as well as isLoading, but it feels pretty opinionated/implementation specifc sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); sentinelObserver.current.observe(ref.current); } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 2050d7d7e42..5bc4570cc5d 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -383,12 +383,7 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T // This is because we don't distinguish between loadingMore and loading in the layout, resulting in a different rect being used to build the body. Perhaps can be considered as a user error // if they pass loadingMore without having any other items in the table. Alternatively, could update the layout so it knows the current loading state. let loadMoreSpinner = ( - // TODO: the below is changed in order NOT to have the layout reserve room for the loading more spinner when loadingState === 'loading' since we have renderEmptyState handle that case - // However, this breaks loading more to fill 1.5X pages of content (aka scrolloffset in useLoadMoreSentinel). I don't think we could have - // the TableLoadingSentinel render both the loadingMore spinner case as well as the initial load spinner case since the latter wants to just be centered in the whole - // table - // - + {loadingState === 'loadingMore' && (
exte } protected buildLoader(node: Node, x: number, y: number): LayoutNode { + let collection = this.virtualizer!.collection; + let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); let rect = new Rect(x, y, this.padding, 0); let layoutInfo = new LayoutInfo('loader', node.key, rect); rect.width = this.virtualizer!.contentSize.width - this.padding - x; - rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + // TODO: Kinda gross but we also have to differentiate between isLoading and isLoadingMore so that we dont' reserve room + // for the loadMore loader row when we are performing initial load. Is this too opinionated? Note that users creating their own layouts + // may need to perform similar logic + rect.height = node.props.isLoading && !isEmptyOrLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; return { layoutInfo, diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 8b710b07a04..55a3f182c42 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -291,7 +291,9 @@ export class TableLayout exten } } - if (children.length === 0) { + // Make sure that the table body gets a height if empty or performing initial load + let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); + if (isEmptyOrLoading) { y = this.virtualizer!.visibleRect.maxY; } else { y -= this.gap; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index dcbf7d22371..d64a58854d8 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -285,9 +285,6 @@ export const AsyncGridListVirtualized = () => { } }); - // TODO: this story also exhibits how the layout is reserving height for the loadingMore spinner row when perform initial load - // even though we only want renderEmptyState to be rendering at that time. The story itself will look fine as is however due to how I've absolutedly - // positioned the renderEmptyState loading spinner... return ( Date: Thu, 24 Apr 2025 11:03:08 -0700 Subject: [PATCH 47/90] add translations and clean up --- packages/@react-aria/utils/src/index.ts | 2 +- .../utils/src/useLoadMoreSentinel.ts | 2 +- packages/@react-spectrum/s2/intl/ar-AE.json | 1 + packages/@react-spectrum/s2/intl/bg-BG.json | 1 + packages/@react-spectrum/s2/intl/cs-CZ.json | 1 + packages/@react-spectrum/s2/intl/da-DK.json | 1 + packages/@react-spectrum/s2/intl/de-DE.json | 1 + packages/@react-spectrum/s2/intl/el-GR.json | 1 + packages/@react-spectrum/s2/intl/es-ES.json | 1 + packages/@react-spectrum/s2/intl/et-EE.json | 1 + packages/@react-spectrum/s2/intl/fi-FI.json | 1 + packages/@react-spectrum/s2/intl/fr-FR.json | 1 + packages/@react-spectrum/s2/intl/he-IL.json | 1 + packages/@react-spectrum/s2/intl/hr-HR.json | 1 + packages/@react-spectrum/s2/intl/hu-HU.json | 1 + packages/@react-spectrum/s2/intl/it-IT.json | 1 + packages/@react-spectrum/s2/intl/ja-JP.json | 1 + packages/@react-spectrum/s2/intl/ko-KR.json | 1 + packages/@react-spectrum/s2/intl/lt-LT.json | 1 + packages/@react-spectrum/s2/intl/lv-LV.json | 1 + packages/@react-spectrum/s2/intl/nb-NO.json | 1 + packages/@react-spectrum/s2/intl/nl-NL.json | 1 + packages/@react-spectrum/s2/intl/pl-PL.json | 1 + packages/@react-spectrum/s2/intl/pt-BR.json | 1 + packages/@react-spectrum/s2/intl/pt-PT.json | 1 + packages/@react-spectrum/s2/intl/ro-RO.json | 1 + packages/@react-spectrum/s2/intl/ru-RU.json | 1 + packages/@react-spectrum/s2/intl/sk-SK.json | 1 + packages/@react-spectrum/s2/intl/sl-SI.json | 1 + packages/@react-spectrum/s2/intl/sr-SP.json | 1 + packages/@react-spectrum/s2/intl/sv-SE.json | 1 + packages/@react-spectrum/s2/intl/tr-TR.json | 1 + packages/@react-spectrum/s2/intl/uk-UA.json | 1 + packages/@react-spectrum/s2/intl/zh-CN.json | 1 + packages/@react-spectrum/s2/intl/zh-TW.json | 1 + packages/@react-spectrum/s2/src/ComboBox.tsx | 3 +- .../s2/stories/ComboBox.stories.tsx | 28 ++++++++++++++++++- .../s2/stories/Picker.stories.tsx | 26 ++++++++++++++++- .../@react-stately/layout/src/ListLayout.ts | 7 +---- .../react-aria-components/src/GridList.tsx | 4 +-- .../react-aria-components/src/ListBox.tsx | 4 +-- packages/react-aria-components/src/Table.tsx | 5 ++-- .../stories/Table.stories.tsx | 3 -- 43 files changed, 95 insertions(+), 22 deletions(-) diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index a4370cea6de..a567ea89667 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -45,7 +45,7 @@ export {useEffectEvent} from './useEffectEvent'; export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; -export {useLoadMoreSentinel} from './useLoadMoreSentinel'; +export {UNSTABLE_useLoadMoreSentinel} from './useLoadMoreSentinel'; export {inertValue} from './inertValue'; export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 0ffe15a71cd..86aca06c5d1 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -28,7 +28,7 @@ export interface LoadMoreSentinelProps extends AsyncLoadable { // TODO: Maybe include a scrollRef option so the user can provide the scrollParent to compare against instead of having us look it up } -export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { +export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { let {isLoading, onLoadMore, scrollOffset = 1} = props; let sentinelObserver = useRef(null); diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index 95e40e61f05..e92dcf4d559 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "تم تحديد الكل", "breadcrumbs.more": "المزيد من العناصر", "button.pending": "قيد الانتظار", + "combobox.noResults": "لا توجد نتائج", "contextualhelp.help": "مساعدة", "contextualhelp.info": "معلومات", "dialog.alert": "تنبيه", diff --git a/packages/@react-spectrum/s2/intl/bg-BG.json b/packages/@react-spectrum/s2/intl/bg-BG.json index 18f43500730..28d91eb2faf 100644 --- a/packages/@react-spectrum/s2/intl/bg-BG.json +++ b/packages/@react-spectrum/s2/intl/bg-BG.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Всички избрани", "breadcrumbs.more": "Още елементи", "button.pending": "недовършено", + "combobox.noResults": "Няма резултати", "contextualhelp.help": "Помощ", "contextualhelp.info": "Информация", "dialog.alert": "Сигнал", diff --git a/packages/@react-spectrum/s2/intl/cs-CZ.json b/packages/@react-spectrum/s2/intl/cs-CZ.json index a52c1cd7283..b62dd999713 100644 --- a/packages/@react-spectrum/s2/intl/cs-CZ.json +++ b/packages/@react-spectrum/s2/intl/cs-CZ.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Vybráno vše", "breadcrumbs.more": "Další položky", "button.pending": "čeká na vyřízení", + "combobox.noResults": "Žádné výsledky", "contextualhelp.help": "Nápověda", "contextualhelp.info": "Informace", "dialog.alert": "Výstraha", diff --git a/packages/@react-spectrum/s2/intl/da-DK.json b/packages/@react-spectrum/s2/intl/da-DK.json index fbe2da96ac3..ae8427a94a3 100644 --- a/packages/@react-spectrum/s2/intl/da-DK.json +++ b/packages/@react-spectrum/s2/intl/da-DK.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Alle valgt", "breadcrumbs.more": "Flere elementer", "button.pending": "afventende", + "combobox.noResults": "Ingen resultater", "contextualhelp.help": "Hjælp", "contextualhelp.info": "Oplysninger", "dialog.alert": "Advarsel", diff --git a/packages/@react-spectrum/s2/intl/de-DE.json b/packages/@react-spectrum/s2/intl/de-DE.json index ee473dee0b9..9b9a5eed62f 100644 --- a/packages/@react-spectrum/s2/intl/de-DE.json +++ b/packages/@react-spectrum/s2/intl/de-DE.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Alles ausgewählt", "breadcrumbs.more": "Weitere Elemente", "button.pending": "Ausstehend", + "combobox.noResults": "Keine Ergebnisse", "contextualhelp.help": "Hilfe", "contextualhelp.info": "Informationen", "dialog.alert": "Warnhinweis", diff --git a/packages/@react-spectrum/s2/intl/el-GR.json b/packages/@react-spectrum/s2/intl/el-GR.json index 779f985711c..ba1632e41d5 100644 --- a/packages/@react-spectrum/s2/intl/el-GR.json +++ b/packages/@react-spectrum/s2/intl/el-GR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Επιλέχθηκαν όλα", "breadcrumbs.more": "Περισσότερα στοιχεία", "button.pending": "σε εκκρεμότητα", + "combobox.noResults": "Χωρίς αποτέλεσμα", "contextualhelp.help": "Βοήθεια", "contextualhelp.info": "Πληροφορίες", "dialog.alert": "Ειδοποίηση", diff --git a/packages/@react-spectrum/s2/intl/es-ES.json b/packages/@react-spectrum/s2/intl/es-ES.json index c9f945d418e..21c72fc3d28 100644 --- a/packages/@react-spectrum/s2/intl/es-ES.json +++ b/packages/@react-spectrum/s2/intl/es-ES.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Todos seleccionados", "breadcrumbs.more": "Más elementos", "button.pending": "pendiente", + "combobox.noResults": "Sin resultados", "contextualhelp.help": "Ayuda", "contextualhelp.info": "Información", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/et-EE.json b/packages/@react-spectrum/s2/intl/et-EE.json index 50720b4b2e1..76028826527 100644 --- a/packages/@react-spectrum/s2/intl/et-EE.json +++ b/packages/@react-spectrum/s2/intl/et-EE.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Kõik valitud", "breadcrumbs.more": "Veel üksusi", "button.pending": "ootel", + "combobox.noResults": "Tulemusi pole", "contextualhelp.help": "Spikker", "contextualhelp.info": "Teave", "dialog.alert": "Teade", diff --git a/packages/@react-spectrum/s2/intl/fi-FI.json b/packages/@react-spectrum/s2/intl/fi-FI.json index 411bd935e9e..6ac21dc05af 100644 --- a/packages/@react-spectrum/s2/intl/fi-FI.json +++ b/packages/@react-spectrum/s2/intl/fi-FI.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Kaikki valittu", "breadcrumbs.more": "Lisää kohteita", "button.pending": "odottaa", + "combobox.noResults": "Ei tuloksia", "contextualhelp.help": "Ohje", "contextualhelp.info": "Tiedot", "dialog.alert": "Hälytys", diff --git a/packages/@react-spectrum/s2/intl/fr-FR.json b/packages/@react-spectrum/s2/intl/fr-FR.json index 18b583ef85a..cc02f393b41 100644 --- a/packages/@react-spectrum/s2/intl/fr-FR.json +++ b/packages/@react-spectrum/s2/intl/fr-FR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Toute la sélection", "breadcrumbs.more": "Plus d’éléments", "button.pending": "En attente", + "combobox.noResults": "Aucun résultat", "contextualhelp.help": "Aide", "contextualhelp.info": "Informations", "dialog.alert": "Alerte", diff --git a/packages/@react-spectrum/s2/intl/he-IL.json b/packages/@react-spectrum/s2/intl/he-IL.json index d021f1051e5..56518b06c97 100644 --- a/packages/@react-spectrum/s2/intl/he-IL.json +++ b/packages/@react-spectrum/s2/intl/he-IL.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "כל הפריטים שנבחרו", "breadcrumbs.more": "פריטים נוספים", "button.pending": "ממתין ל", + "combobox.noResults": "אין תוצאות", "contextualhelp.help": "עזרה", "contextualhelp.info": "מידע", "dialog.alert": "התראה", diff --git a/packages/@react-spectrum/s2/intl/hr-HR.json b/packages/@react-spectrum/s2/intl/hr-HR.json index 126c82525b4..1c4d81a5dd1 100644 --- a/packages/@react-spectrum/s2/intl/hr-HR.json +++ b/packages/@react-spectrum/s2/intl/hr-HR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Sve odabrano", "breadcrumbs.more": "Više stavki", "button.pending": "u tijeku", + "combobox.noResults": "Nema rezultata", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", "dialog.alert": "Upozorenje", diff --git a/packages/@react-spectrum/s2/intl/hu-HU.json b/packages/@react-spectrum/s2/intl/hu-HU.json index 9cd349bd6ef..e6095a948a6 100644 --- a/packages/@react-spectrum/s2/intl/hu-HU.json +++ b/packages/@react-spectrum/s2/intl/hu-HU.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Mind kijelölve", "breadcrumbs.more": "További elemek", "button.pending": "függőben levő", + "combobox.noResults": "Nincsenek találatok", "contextualhelp.help": "Súgó", "contextualhelp.info": "Információ", "dialog.alert": "Figyelmeztetés", diff --git a/packages/@react-spectrum/s2/intl/it-IT.json b/packages/@react-spectrum/s2/intl/it-IT.json index 23b02a7494f..542f75977a7 100644 --- a/packages/@react-spectrum/s2/intl/it-IT.json +++ b/packages/@react-spectrum/s2/intl/it-IT.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Tutti selezionati", "breadcrumbs.more": "Altri elementi", "button.pending": "in sospeso", + "combobox.noResults": "Nessun risultato", "contextualhelp.help": "Aiuto", "contextualhelp.info": "Informazioni", "dialog.alert": "Avviso", diff --git a/packages/@react-spectrum/s2/intl/ja-JP.json b/packages/@react-spectrum/s2/intl/ja-JP.json index 14b470870d9..ef2a032f26c 100644 --- a/packages/@react-spectrum/s2/intl/ja-JP.json +++ b/packages/@react-spectrum/s2/intl/ja-JP.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "すべてを選択", "breadcrumbs.more": "その他の項目", "button.pending": "保留", + "combobox.noResults": "結果なし", "contextualhelp.help": "ヘルプ", "contextualhelp.info": "情報", "dialog.alert": "アラート", diff --git a/packages/@react-spectrum/s2/intl/ko-KR.json b/packages/@react-spectrum/s2/intl/ko-KR.json index 91ddba3b662..616ccc9801c 100644 --- a/packages/@react-spectrum/s2/intl/ko-KR.json +++ b/packages/@react-spectrum/s2/intl/ko-KR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "모두 선택됨", "breadcrumbs.more": "기타 항목", "button.pending": "보류 중", + "combobox.noResults": "결과 없음", "contextualhelp.help": "도움말", "contextualhelp.info": "정보", "dialog.alert": "경고", diff --git a/packages/@react-spectrum/s2/intl/lt-LT.json b/packages/@react-spectrum/s2/intl/lt-LT.json index 7b3b32e0f0e..69bd4e00d0b 100644 --- a/packages/@react-spectrum/s2/intl/lt-LT.json +++ b/packages/@react-spectrum/s2/intl/lt-LT.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Pasirinkta viskas", "breadcrumbs.more": "Daugiau elementų", "button.pending": "laukiama", + "combobox.noResults": "Be rezultatų", "contextualhelp.help": "Žinynas", "contextualhelp.info": "Informacija", "dialog.alert": "Įspėjimas", diff --git a/packages/@react-spectrum/s2/intl/lv-LV.json b/packages/@react-spectrum/s2/intl/lv-LV.json index ba7807815c4..bc6cb4bf55b 100644 --- a/packages/@react-spectrum/s2/intl/lv-LV.json +++ b/packages/@react-spectrum/s2/intl/lv-LV.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Atlasīts viss", "breadcrumbs.more": "Vairāk vienumu", "button.pending": "gaida", + "combobox.noResults": "Nav rezultātu", "contextualhelp.help": "Palīdzība", "contextualhelp.info": "Informācija", "dialog.alert": "Brīdinājums", diff --git a/packages/@react-spectrum/s2/intl/nb-NO.json b/packages/@react-spectrum/s2/intl/nb-NO.json index e8d2cf3b5ce..0b8e563facf 100644 --- a/packages/@react-spectrum/s2/intl/nb-NO.json +++ b/packages/@react-spectrum/s2/intl/nb-NO.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Alle er valgt", "breadcrumbs.more": "Flere elementer", "button.pending": "avventer", + "combobox.noResults": "Ingen resultater", "contextualhelp.help": "Hjelp", "contextualhelp.info": "Informasjon", "dialog.alert": "Varsel", diff --git a/packages/@react-spectrum/s2/intl/nl-NL.json b/packages/@react-spectrum/s2/intl/nl-NL.json index ff47ead39b9..a7e9d07f0eb 100644 --- a/packages/@react-spectrum/s2/intl/nl-NL.json +++ b/packages/@react-spectrum/s2/intl/nl-NL.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Alles geselecteerd", "breadcrumbs.more": "Meer items", "button.pending": "in behandeling", + "combobox.noResults": "Geen resultaten", "contextualhelp.help": "Help", "contextualhelp.info": "Informatie", "dialog.alert": "Melding", diff --git a/packages/@react-spectrum/s2/intl/pl-PL.json b/packages/@react-spectrum/s2/intl/pl-PL.json index 89c877b0734..3d7396179cd 100644 --- a/packages/@react-spectrum/s2/intl/pl-PL.json +++ b/packages/@react-spectrum/s2/intl/pl-PL.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Wszystkie zaznaczone", "breadcrumbs.more": "Więcej elementów", "button.pending": "oczekujące", + "combobox.noResults": "Brak wyników", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informacja", "dialog.alert": "Ostrzeżenie", diff --git a/packages/@react-spectrum/s2/intl/pt-BR.json b/packages/@react-spectrum/s2/intl/pt-BR.json index baaefc42493..f8a9d36c515 100644 --- a/packages/@react-spectrum/s2/intl/pt-BR.json +++ b/packages/@react-spectrum/s2/intl/pt-BR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Todos selecionados", "breadcrumbs.more": "Mais itens", "button.pending": "pendente", + "combobox.noResults": "Nenhum resultado", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informações", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/pt-PT.json b/packages/@react-spectrum/s2/intl/pt-PT.json index 9ac3b93457e..0061e50f84a 100644 --- a/packages/@react-spectrum/s2/intl/pt-PT.json +++ b/packages/@react-spectrum/s2/intl/pt-PT.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Tudo selecionado", "breadcrumbs.more": "Mais artigos", "button.pending": "pendente", + "combobox.noResults": "Sem resultados", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informação", "dialog.alert": "Alerta", diff --git a/packages/@react-spectrum/s2/intl/ro-RO.json b/packages/@react-spectrum/s2/intl/ro-RO.json index 1dbb1fb216c..96220534251 100644 --- a/packages/@react-spectrum/s2/intl/ro-RO.json +++ b/packages/@react-spectrum/s2/intl/ro-RO.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Toate elementele selectate", "breadcrumbs.more": "Mai multe articole", "button.pending": "în așteptare", + "combobox.noResults": "Niciun rezultat", "contextualhelp.help": "Ajutor", "contextualhelp.info": "Informații", "dialog.alert": "Alertă", diff --git a/packages/@react-spectrum/s2/intl/ru-RU.json b/packages/@react-spectrum/s2/intl/ru-RU.json index 0568d043d17..9cef1ff3070 100644 --- a/packages/@react-spectrum/s2/intl/ru-RU.json +++ b/packages/@react-spectrum/s2/intl/ru-RU.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Выбрано все", "breadcrumbs.more": "Дополнительные элементы", "button.pending": "в ожидании", + "combobox.noResults": "Результаты отсутствуют", "contextualhelp.help": "Справка", "contextualhelp.info": "Информация", "dialog.alert": "Предупреждение", diff --git a/packages/@react-spectrum/s2/intl/sk-SK.json b/packages/@react-spectrum/s2/intl/sk-SK.json index a5f7f7324d8..307ad653033 100644 --- a/packages/@react-spectrum/s2/intl/sk-SK.json +++ b/packages/@react-spectrum/s2/intl/sk-SK.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Všetky vybraté položky", "breadcrumbs.more": "Ďalšie položky", "button.pending": "čakajúce", + "combobox.noResults": "Žiadne výsledky", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informácie", "dialog.alert": "Upozornenie", diff --git a/packages/@react-spectrum/s2/intl/sl-SI.json b/packages/@react-spectrum/s2/intl/sl-SI.json index 307f8a52711..ef8bab0ed5d 100644 --- a/packages/@react-spectrum/s2/intl/sl-SI.json +++ b/packages/@react-spectrum/s2/intl/sl-SI.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Izbrano vse", "breadcrumbs.more": "Več elementov", "button.pending": "v teku", + "combobox.noResults": "Ni rezultatov", "contextualhelp.help": "Pomoč", "contextualhelp.info": "Informacije", "dialog.alert": "Opozorilo", diff --git a/packages/@react-spectrum/s2/intl/sr-SP.json b/packages/@react-spectrum/s2/intl/sr-SP.json index 4468239ec20..c33b49237b1 100644 --- a/packages/@react-spectrum/s2/intl/sr-SP.json +++ b/packages/@react-spectrum/s2/intl/sr-SP.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Sve je izabrano", "breadcrumbs.more": "Više stavki", "button.pending": "nerešeno", + "combobox.noResults": "Nema rezultata", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", "dialog.alert": "Upozorenje", diff --git a/packages/@react-spectrum/s2/intl/sv-SE.json b/packages/@react-spectrum/s2/intl/sv-SE.json index f9a1ab96403..de5823dd3f6 100644 --- a/packages/@react-spectrum/s2/intl/sv-SE.json +++ b/packages/@react-spectrum/s2/intl/sv-SE.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Alla markerade", "breadcrumbs.more": "Fler artiklar", "button.pending": "väntande", + "combobox.noResults": "Inga resultat", "contextualhelp.help": "Hjälp", "contextualhelp.info": "Information", "dialog.alert": "Varning", diff --git a/packages/@react-spectrum/s2/intl/tr-TR.json b/packages/@react-spectrum/s2/intl/tr-TR.json index 21b04418c28..5d6e707d20c 100644 --- a/packages/@react-spectrum/s2/intl/tr-TR.json +++ b/packages/@react-spectrum/s2/intl/tr-TR.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Tümü seçildi", "breadcrumbs.more": "Daha fazla öğe", "button.pending": "beklemede", + "combobox.noResults": "Sonuç yok", "contextualhelp.help": "Yardım", "contextualhelp.info": "Bilgiler", "dialog.alert": "Uyarı", diff --git a/packages/@react-spectrum/s2/intl/uk-UA.json b/packages/@react-spectrum/s2/intl/uk-UA.json index ef8515e6af8..eb68272c836 100644 --- a/packages/@react-spectrum/s2/intl/uk-UA.json +++ b/packages/@react-spectrum/s2/intl/uk-UA.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "Усе вибрано", "breadcrumbs.more": "Більше елементів", "button.pending": "в очікуванні", + "combobox.noResults": "Результатів немає", "contextualhelp.help": "Довідка", "contextualhelp.info": "Інформація", "dialog.alert": "Сигнал тривоги", diff --git a/packages/@react-spectrum/s2/intl/zh-CN.json b/packages/@react-spectrum/s2/intl/zh-CN.json index ccea4ddc703..20fd66d9ec9 100644 --- a/packages/@react-spectrum/s2/intl/zh-CN.json +++ b/packages/@react-spectrum/s2/intl/zh-CN.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "全选", "breadcrumbs.more": "更多项目", "button.pending": "待处理", + "combobox.noResults": "无结果", "contextualhelp.help": "帮助", "contextualhelp.info": "信息", "dialog.alert": "警报", diff --git a/packages/@react-spectrum/s2/intl/zh-TW.json b/packages/@react-spectrum/s2/intl/zh-TW.json index 17466467b56..958efa4265a 100644 --- a/packages/@react-spectrum/s2/intl/zh-TW.json +++ b/packages/@react-spectrum/s2/intl/zh-TW.json @@ -6,6 +6,7 @@ "actionbar.selectedAll": "已選取所有項目", "breadcrumbs.more": "更多項目", "button.pending": "待處理", + "combobox.noResults": "無任何結果", "contextualhelp.help": "說明", "contextualhelp.info": "資訊", "dialog.alert": "警示", diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index b45fd278cc3..17e57dbf33c 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -440,7 +440,7 @@ export function ComboBoxSection(props: ComboBoxSectionProps } // TODO: not quite sure why typescript is complaining when I types this as T extends object -const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps & {isOpen: boolean}, ref: ForwardedRef) { +const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps & {isOpen: boolean}, ref: ForwardedRef) { let { direction = 'bottom', align = 'start', @@ -563,7 +563,6 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 55493f09b68..1282724da87 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -253,7 +253,33 @@ export const AsyncComboBoxStory = { ...Example.args, label: 'Star Wars Character Lookup' }, - name: 'Async loading combobox' + name: 'Async loading combobox', + parameters: { + docs: { + source: { + transform: () => { + return ` +let list = useAsyncList({ + async load({signal, cursor, filterText}) { + // API call here + ... + } +}); + +return ( + + {(item: Character) => {item.name}} + +);`; + } + } + } + } }; export const EmptyCombobox = { diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index faa5debfd9b..481316ae804 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -262,5 +262,29 @@ export const AsyncPickerStory = { args: { ...Example.args }, - name: 'Async loading picker' + name: 'Async loading picker', + parameters: { + docs: { + source: { + transform: () => { + return ` +let list = useAsyncList({ + async load({signal, cursor}) { + // API call here + ... + } +}); + +return ( + + {item => {item.name}} + +);`; + } + } + } + } }; diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 8a8a840d37b..f5cac55823f 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -279,11 +279,6 @@ export class ListLayout exte // Always add the loader sentinel if present. This assumes the loader is the last option/row // will need to refactor when handling multi section loading - // TODO: One issue here is that after new items are loaded, the scroll position "resets" so the the bottom of the scroll body is flush with the bottom of the item that - // was just before the loader, presumabally because the loader collapses to 0 height? Bit annoying since - // prior to the changes in this commit it would replace the loader's node with the newly loaded item node, aka preserving the scroll position. - // However that had an issue with adding too much space at the bottom of the list when you exhausted your api's full set of items due to an errornous calculation being made - // Note that this only an issue with virtualized versions of ListBox and TableView if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') { let loader = this.buildChild(lastNode, this.padding, y, null); nodes.push(loader); @@ -347,7 +342,7 @@ export class ListLayout exte let rect = new Rect(x, y, this.padding, 0); let layoutInfo = new LayoutInfo('loader', node.key, rect); rect.width = this.virtualizer!.contentSize.width - this.padding - x; - // TODO: Kinda gross but we also have to differentiate between isLoading and isLoadingMore so that we dont' reserve room + // TODO: Kinda gross but we also have to differentiate between isLoading and isLoadingMore so that we dont'reserve room // for the loadMore loader row when we are performing initial load. Is this too opinionated? Note that users creating their own layouts // may need to perform similar logic rect.height = node.props.isLoading && !isEmptyOrLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 3eac754fa1e..af02c0ecd13 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -18,7 +18,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, LoadMoreSentinelProps, UNSTABLE_useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {ListStateContext} from './ListBox'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -514,7 +514,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu sentinelRef, scrollOffset }), [isLoading, onLoadMore, scrollOffset, state?.collection]); - useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ ...otherProps, diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index bb20ebc1b07..f63f1f949ad 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -17,7 +17,7 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; -import {filterDOMProps, inertValue, LoadMoreSentinelProps, mergeRefs, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {filterDOMProps, inertValue, LoadMoreSentinelProps, mergeRefs, UNSTABLE_useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -484,7 +484,7 @@ export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', fun sentinelRef, scrollOffset }), [isLoading, onLoadMore, scrollOffset, state?.collection]); - useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ ...otherProps, id: undefined, diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 68f4521f8a2..4c416eef6d8 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -10,7 +10,7 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} 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, inertValue, isScrollable, LoadMoreSentinelProps, mergeRefs, useLayoutEffect, useLoadMoreSentinel, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {filterDOMProps, inertValue, isScrollable, LoadMoreSentinelProps, mergeRefs, UNSTABLE_useLoadMoreSentinel, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -1366,7 +1366,7 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct sentinelRef, scrollOffset }), [isLoading, onLoadMore, scrollOffset, state?.collection]); - useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ ...otherProps, @@ -1391,7 +1391,6 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct return ( <> - {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} {/* TODO weird structure? Renders a extra row but we hide via inert so maybe ok? */} {/* @ts-ignore - compatibility with React < 19 */}
diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 831b86ea233..61e15e33064 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -844,9 +844,6 @@ function VirtualizedTableWithEmptyState(args) { renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> {(item) => ( From cbd91b5e661171802d755485b29f29ee4a2979ea Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Apr 2025 11:13:54 -0700 Subject: [PATCH 48/90] get rid of flex: none since loader is part of virtualized collection was only needed when rendering the loader after the virutalizer div --- packages/react-aria-components/src/Virtualizer.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 3334eff7700..affe32c2442 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -107,9 +107,7 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat } return ( - // TODO: temporarily hack styling so the load more sentinel is properly positioned - // (aka we need the virtualizer content wrapper to take its full height/width if its is in a flex parent) -
+
{renderChildren(null, state.visibleViews, renderDropIndicator)} From 4a62993d1a083fea7bc3974c8509e9caacbf3a62 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 24 Apr 2025 11:20:59 -0700 Subject: [PATCH 49/90] fix lint --- packages/@react-spectrum/s2/src/ComboBox.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 17e57dbf33c..d2641df0969 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -439,8 +439,7 @@ export function ComboBoxSection(props: ComboBoxSectionProps ); } -// TODO: not quite sure why typescript is complaining when I types this as T extends object -const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps & {isOpen: boolean}, ref: ForwardedRef) { +const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps & {isOpen: boolean}, ref: ForwardedRef) { let { direction = 'bottom', align = 'start', From 85440fc2eca967e59e48e0ff465be76757eaa752 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 24 Apr 2025 16:13:26 -0700 Subject: [PATCH 50/90] update grid areas and fix edgeToText --- packages/@react-spectrum/s2/src/ComboBox.tsx | 37 ++++++++------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 682834450fe..867a6d6f19e 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -186,15 +186,15 @@ export let listboxItem = style({ gridColumnEnd: -1, display: 'grid', gridTemplateAreas: [ - '. checkmark icon label value keyboard descriptor .', - '. . . description . . . .' + '. checkmark icon label .', + '. . . description .' ], gridTemplateColumns: { size: { - S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], - M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], - L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], - XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] + S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(24)], + M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(32)], + L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(40)], + XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(48)] } }, gridTemplateRows: { @@ -223,10 +223,10 @@ export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ paddingY: centerPadding(), marginX: { size: { - S: '[calc(24 * 3 / 8)]', - M: '[calc(32 * 3 / 8)]', - L: '[calc(40 * 3 / 8)]', - XL: '[calc(48 * 3 / 8)]' + S: `[${edgeToText(24)}]`, + M: `[${edgeToText(24)}]`, + L: `[${edgeToText(24)}]`, + XL: `[${edgeToText(24)}]` } } }); @@ -244,20 +244,13 @@ const separatorWrapper = style({ ':is(:last-child > &)': 'none', default: 'flex' }, - // marginX: { - // size: { - // S: edgeToText(24), - // M: edgeToText(32), - // L: edgeToText(40), - // XL: edgeToText(48) - // } - // }, + // A workaround since edgeToText() returns undefined for some reason marginX: { size: { - S: '[calc(24 * 3 / 8)]', - M: '[calc(32 * 3 / 8)]', - L: '[calc(40 * 3 / 8)]', - XL: '[calc(48 * 3 / 8)]' + S: `[${edgeToText(24)}]`, + M: `[${edgeToText(24)}]`, + L: `[${edgeToText(24)}]`, + XL: `[${edgeToText(24)}]` } }, height: 12 From 09825f4ed50131d018060f8f001ec93fc83e56eb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Apr 2025 13:12:02 -0700 Subject: [PATCH 51/90] prevent the empty w/ loading sentinel select from opening on arrow down --- packages/@react-stately/combobox/package.json | 3 +- packages/@react-stately/select/package.json | 4 ++- .../select/src/useSelectState.ts | 33 ++++++++++--------- packages/react-stately/package.json | 3 +- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index 1cb029b1b47..b4d1452b71f 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -33,7 +33,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index e5252c81822..2e46b1c0954 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -22,6 +22,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/utils": "^3.28.2", "@react-stately/form": "^3.1.3", "@react-stately/list": "^3.12.1", "@react-stately/overlays": "^3.6.15", @@ -30,7 +31,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index c8a3ac84040..771104b7799 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -15,7 +15,8 @@ import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {SelectProps} from '@react-types/select'; import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; -import {useState} from 'react'; +import {useEffectEvent} from '@react-aria/utils'; +import {useMemo, useState} from 'react'; export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} @@ -62,26 +63,28 @@ export function useSelectState(props: SelectStateOptions): }); let [isFocused, setFocused] = useState(false); + let isEmpty = useMemo(() => listState.collection.size === 0 || (listState.collection.size === 1 && listState.collection.getItem(listState.collection.getFirstKey()!)?.type === 'loader'), [listState.collection]); + let open = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.open(); + } + }); + + let toggle = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.toggle(); + } + }); return { ...validationState, ...listState, ...triggerState, focusStrategy, - open(focusStrategy: FocusStrategy | null = null) { - // Don't open if the collection is empty. - let isEmpty = listState.collection.size === 0 || (listState.collection.size === 1 && listState.collection.getItem(listState.collection.getFirstKey()!)?.type === 'loader'); - if (!isEmpty) { - setFocusStrategy(focusStrategy); - triggerState.open(); - } - }, - toggle(focusStrategy: FocusStrategy | null = null) { - if (listState.collection.size !== 0) { - setFocusStrategy(focusStrategy); - triggerState.toggle(); - } - }, + open, + toggle, isFocused, setFocused }; diff --git a/packages/react-stately/package.json b/packages/react-stately/package.json index 7caf830b77b..dd6bc73829c 100644 --- a/packages/react-stately/package.json +++ b/packages/react-stately/package.json @@ -52,7 +52,8 @@ "@react-types/shared": "^3.29.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "devDependencies": { "@babel/cli": "^7.24.1", From d98691f72913d54747d5a5ceacbaa13dcbdd84df Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Apr 2025 14:20:14 -0700 Subject: [PATCH 52/90] adding chromatic tests for S2 Combobox/Picker async loading --- package.json | 1 + .../s2/chromatic/Combobox.stories.tsx | 81 ++++++++++- .../s2/chromatic/Picker.stories.tsx | 56 +++++++- packages/@react-spectrum/s2/package.json | 1 + yarn.lock | 134 +++++++++++++++++- 5 files changed, 267 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 0017e10be7f..0ea0c33fc9e 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@storybook/addon-themes": "^7.6.19", "@storybook/api": "^7.6.19", "@storybook/components": "^7.6.19", + "@storybook/jest": "^0.2.3", "@storybook/manager-api": "^7.6.19", "@storybook/preview": "^7.6.19", "@storybook/preview-api": "^7.6.19", diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index a7d43aadb4d..e285f1a6c94 100644 --- a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx @@ -10,15 +10,17 @@ * governing permissions and limitations under the License. */ +import {AsyncComboBoxStory, ContextualHelpExample, CustomWidth, Dynamic, EmptyCombobox, Example, Sections, WithIcons} from '../stories/ComboBox.stories'; import {ComboBox} from '../src'; -import {ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/ComboBox.stories'; +import {expect} from '@storybook/jest'; import type {Meta, StoryObj} from '@storybook/react'; -import {userEvent, within} from '@storybook/testing-library'; +import {userEvent, waitFor, within} from '@storybook/testing-library'; const meta: Meta> = { component: ComboBox, parameters: { - chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true} + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true}, + chromatic: {ignoreSelectors: ['[role="progressbar"]']} }, tags: ['autodocs'], title: 'S2 Chromatic/ComboBox' @@ -69,3 +71,76 @@ export const WithCustomWidth = { ...CustomWidth, play: async (context) => await Static.play!(context) } as StoryObj; + +export const WithEmptyState = { + ...EmptyCombobox, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByText('No results'); + } +}; + +// TODO: this one is probably not great for chromatic since it has the spinner, check if ignoreSelectors works for it +export const WithInitialLoading = { + ...EmptyCombobox, + args: { + loadingState: 'loading' + }, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByText('Loading', {exact: false}); + } +}; + +export const WithLoadMore = { + ...Example, + args: { + loadingState: 'loadingMore' + }, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByRole('progressbar'); + } +}; + +export const AsyncResults = { + ...AsyncComboBoxStory, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + } +}; + +export const Filtering = { + ...AsyncComboBoxStory, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + + let combobox = await within(body).findByRole('combobox'); + await userEvent.type(combobox, 'R2'); + + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx index 6e2cfab7311..5960a87cb58 100644 --- a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/Picker.stories'; +import {AsyncPickerStory, ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/Picker.stories'; +import {expect} from '@storybook/jest'; import type {Meta, StoryObj} from '@storybook/react'; import {Picker} from '../src'; -import {userEvent, within} from '@storybook/testing-library'; +import {userEvent, waitFor, within} from '@storybook/testing-library'; const meta: Meta> = { component: Picker, @@ -68,3 +69,54 @@ export const ContextualHelp = { } }; +export const EmptyAndLoading = { + render: () => ( + + {[]} + + ), + play: async ({canvasElement}) => { + let body = canvasElement.ownerDocument.body; + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + expect(within(body).queryByRole('listbox')).toBeFalsy(); + } +}; + +export const AsyncResults = { + ...AsyncPickerStory, + play: async ({canvasElement}) => { + let body = canvasElement.ownerDocument.body; + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + await userEvent.tab(); + + await waitFor(() => { + expect(within(body).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); + + await userEvent.keyboard('{ArrowDown}'); + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + + await waitFor(() => { + expect(within(listbox).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + + await waitFor(() => { + expect(within(listbox).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); + + await userEvent.keyboard('{PageDown}'); + + await waitFor(() => { + expect(within(listbox).getByText('Greedo', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + } +}; diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 74c0a29fcda..4e0adcb8e9b 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -123,6 +123,7 @@ "@adobe/spectrum-tokens": "^13.0.0-beta.56", "@parcel/macros": "^2.14.0", "@react-aria/test-utils": "1.0.0-alpha.3", + "@storybook/jest": "^0.2.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", diff --git a/yarn.lock b/yarn.lock index b5e38ee2f6f..ffdceeb7d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.2 + resolution: "@adobe/css-tools@npm:4.4.2" + checksum: 10c0/19433666ad18536b0ed05d4b53fbb3dd6ede266996796462023ec77a90b484890ad28a3e528cdf3ab8a65cb2fcdff5d8feb04db6bc6eed6ca307c40974239c94 + languageName: node + linkType: hard + "@adobe/react-spectrum-ui@npm:1.2.1": version: 1.2.1 resolution: "@adobe/react-spectrum-ui@npm:1.2.1" @@ -2979,6 +2986,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:^28.1.3": + version: 28.1.3 + resolution: "@jest/schemas@npm:28.1.3" + dependencies: + "@sinclair/typebox": "npm:^0.24.1" + checksum: 10c0/8c325918f3e1b83e687987b05c2e5143d171f372b091f891fe17835f06fadd864ddae3c7e221a704bdd7e2ea28c4b337124c02023d8affcbdd51eca2879162ac + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -7957,6 +7973,7 @@ __metadata: "@react-types/shared": "npm:^3.29.0" "@react-types/table": "npm:^3.12.0" "@react-types/textfield": "npm:^3.12.1" + "@storybook/jest": "npm:^0.2.3" "@testing-library/dom": "npm:^10.1.0" "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" @@ -8556,6 +8573,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -8744,6 +8762,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/select@workspace:packages/@react-stately/select" dependencies: + "@react-aria/utils": "npm:^3.28.2" "@react-stately/form": "npm:^3.1.3" "@react-stately/list": "npm:^3.12.1" "@react-stately/overlays": "npm:^3.6.15" @@ -8752,6 +8771,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -9429,6 +9449,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.24.1": + version: 0.24.51 + resolution: "@sinclair/typebox@npm:0.24.51" + checksum: 10c0/458131e83ca59ad3721f0abeef2aa5220aff2083767e1143d75c67c85d55ef7a212f48f394471ee6bdd2e860ba30f09a489cdd2a28a2824d5b0d1014bdfb2552 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -10182,6 +10209,15 @@ __metadata: languageName: node linkType: hard +"@storybook/expect@npm:storybook-jest": + version: 28.1.3-5 + resolution: "@storybook/expect@npm:28.1.3-5" + dependencies: + "@types/jest": "npm:28.1.3" + checksum: 10c0/ea912b18e1353cdd3bbdf93667ffebca7f843fa28a01e647429bffa6cb074afd4401d13eb2ecbfc9714e100e128ec1fe2686bded52e9e378ce44774889563558 + languageName: node + linkType: hard + "@storybook/global@npm:^5.0.0": version: 5.0.0 resolution: "@storybook/global@npm:5.0.0" @@ -10189,6 +10225,18 @@ __metadata: languageName: node linkType: hard +"@storybook/jest@npm:^0.2.3": + version: 0.2.3 + resolution: "@storybook/jest@npm:0.2.3" + dependencies: + "@storybook/expect": "npm:storybook-jest" + "@testing-library/jest-dom": "npm:^6.1.2" + "@types/jest": "npm:28.1.3" + jest-mock: "npm:^27.3.0" + checksum: 10c0/a2c367649ae53d9385b16f49bd73d5a928a2c3b9e64c2efcc1bbfc081b3b75972293bbe0e1828b67c94f0c2ed96341e0fae0ad5e30484a0ed4715724bbbf2c76 + languageName: node + linkType: hard + "@storybook/manager-api@npm:7.6.20, @storybook/manager-api@npm:^7.0.0, @storybook/manager-api@npm:^7.6.19": version: 7.6.20 resolution: "@storybook/manager-api@npm:7.6.20" @@ -10917,6 +10965,21 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.1.2": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10c0/5566b6c0b7b0709bc244aec3aa3dc9e5f4663e8fb2b99d8cd456fc07279e59db6076cbf798f9d3099a98fca7ef4cd50e4e1f4c4dec5a60a8fad8d24a638a5bf6 + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.0": version: 16.2.0 resolution: "@testing-library/react@npm:16.2.0" @@ -11330,6 +11393,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:28.1.3": + version: 28.1.3 + resolution: "@types/jest@npm:28.1.3" + dependencies: + jest-matcher-utils: "npm:^28.0.0" + pretty-format: "npm:^28.0.0" + checksum: 10c0/d295db8680b5c230698345d6caae621ea9fa8720309027e2306fabfd8769679b4bd7474b4f6e03788905c934eff62105bc0a3e3f1e174feee51b4551d49ac42a + languageName: node + linkType: hard + "@types/jscodeshift@npm:^0.11.11": version: 0.11.11 resolution: "@types/jscodeshift@npm:0.11.11" @@ -16598,6 +16671,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^28.1.1": + version: 28.1.1 + resolution: "diff-sequences@npm:28.1.1" + checksum: 10c0/26f29fa3f6b8c9040c3c6f6dab85413d90a09c8e6cb17b318bbcf64f225d7dcb1fb64392f3a9919a90888b434c4f6c8a4cc4f807aad02bbabae912c5d13c31f7 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -16695,6 +16775,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" @@ -22660,6 +22747,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-diff@npm:28.1.3" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^28.1.1" + jest-get-type: "npm:^28.0.2" + pretty-format: "npm:^28.1.3" + checksum: 10c0/17a101ceb7e8f25c3ef64edda15cb1a259c2835395637099f3cc44f578fbd94ced7a13d11c0cbe8c5c1c3959a08544f0a913bec25a305b6dfc9847ce488e7198 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -22736,6 +22835,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^28.0.2": + version: 28.0.2 + resolution: "jest-get-type@npm:28.0.2" + checksum: 10c0/f64a40cfa10d79a56b383919033d35c8c4daee6145a1df31ec5ef2283fa7e8adbd443c6fcb4cfd0f60bbbd89f046c2323952f086b06e875cbbbc1a7d543a6e5e + languageName: node + linkType: hard + "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -22800,6 +22906,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^28.0.0": + version: 28.1.3 + resolution: "jest-matcher-utils@npm:28.1.3" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^28.1.3" + jest-get-type: "npm:^28.0.2" + pretty-format: "npm:^28.1.3" + checksum: 10c0/026fbe664cfdaed5a5c9facfc86ccc9bed3718a7d1fe061e355eb6158019a77f74e9b843bc99f9a467966cbebe60bde8b43439174cbf64997d4ad404f8f809d0 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -22838,7 +22956,7 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^27.0.6": +"jest-mock@npm:^27.0.6, jest-mock@npm:^27.3.0": version: 27.5.1 resolution: "jest-mock@npm:27.5.1" dependencies: @@ -28698,6 +28816,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^28.0.0, pretty-format@npm:^28.1.3": + version: 28.1.3 + resolution: "pretty-format@npm:28.1.3" + dependencies: + "@jest/schemas": "npm:^28.1.3" + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/596d8b459b6fdac7dcbd70d40169191e889939c17ffbcc73eebe2a9a6f82cdbb57faffe190274e0a507d9ecdf3affadf8a9b43442a625eecfbd2813b9319660f + languageName: node + linkType: hard + "pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -29564,6 +29694,7 @@ __metadata: "@storybook/addon-themes": "npm:^7.6.19" "@storybook/api": "npm:^7.6.19" "@storybook/components": "npm:^7.6.19" + "@storybook/jest": "npm:^0.2.3" "@storybook/manager-api": "npm:^7.6.19" "@storybook/preview": "npm:^7.6.19" "@storybook/preview-api": "npm:^7.6.19" @@ -29701,6 +29832,7 @@ __metadata: "@react-types/shared": "npm:^3.29.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft From f34fc0ceb2febba6242ab3f96d66dde39b5e6425 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Apr 2025 16:38:39 -0700 Subject: [PATCH 53/90] making sure sentinel is rendered even when empty for cases like Listbox in Combobox, the content area is actually 0 since we dont have a height on the listbox nor does it reserve room like table layout --- packages/react-aria-components/src/Virtualizer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index affe32c2442..cc729defa16 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -102,7 +102,9 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat onScrollEnd: state.endScrolling }, scrollRef!); - if (state.contentSize.area === 0) { + // TODO: wull have to update this when multi section loading is implemented, will need to check if all items in a collection are loaders instead perhaps + let hasLoadingSentinel = collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'; + if (state.contentSize.area === 0 && !hasLoadingSentinel) { return null; } From abfcde02c28c5db528347e7c605727a3379266b6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 25 Apr 2025 16:48:37 -0700 Subject: [PATCH 54/90] update gridlist stories so that it is easier to see useLoadMore is only called once per load due to the number of items returned by the star wars api and the height of the rows, making the gridlist too tall causes the loadmore to be called multiple times in order to fill enough content for a single page --- packages/react-aria-components/stories/GridList.stories.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index d64a58854d8..6b7362f2d46 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -254,9 +254,8 @@ export const AsyncGridList = () => { return ( renderEmptyState({isLoading: list.isLoading})}> {(item: Character) => ( @@ -294,7 +293,7 @@ export const AsyncGridListVirtualized = () => { }}> renderEmptyState({isLoading: list.isLoading})}> From 5258d8bafd8f465de4344e8837e5145a93666499 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 28 Apr 2025 13:02:10 -0700 Subject: [PATCH 55/90] add gridlist tests for loadmore --- .../test/GridList.test.js | 220 +++++++++++++++++- 1 file changed, 213 insertions(+), 7 deletions(-) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c3a437229c4..0a4db267a6a 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import { Button, Checkbox, + Collection, Dialog, DialogTrigger, DropIndicator, @@ -32,6 +33,7 @@ import { } from '../'; import {getFocusableTreeWalker} from '@react-aria/focus'; import React from 'react'; +import {UNSTABLE_GridListLoadingSentinel} from '../src/GridList'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -827,9 +829,9 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -839,9 +841,9 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -851,11 +853,215 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); }); + + describe('async loading', () => { + let items = [ + {name: 'Foo'}, + {name: 'Bar'}, + {name: 'Baz'} + ]; + let renderEmptyState = () => { + return ( +
empty state
+ ); + }; + let AsyncGridList = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + ); + }; + + let onLoadMore = jest.fn(); + let observe = jest.fn(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the loading element when loading', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(4); + let loaderRow = rows[3]; + expect(loaderRow).toHaveTextContent('Loading...'); + + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should render the sentinel but not the loading indicator when not loading', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(3); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should properly render the renderEmptyState if gridlist is empty, even when loading', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent('empty state'); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveTextContent('empty state'); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should only fire loadMore when not loading and intersection is detected', async () => { + let observer = setupIntersectionObserverMock({ + observe + }); + + let tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + tree.rerender(); + expect(observe).toHaveBeenCalledTimes(3); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + describe('virtualized', () => { + let items = []; + for (let i = 0; i < 50; i++) { + items.push({name: 'Foo' + i}); + } + let clientWidth, clientHeight; + + beforeAll(() => { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let VirtualizedAsyncGridList = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + + ); + }; + + it('should always render the sentinel even when virtualized', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(8); + let loaderRow = rows[7]; + expect(loaderRow).toHaveTextContent('Loading...'); + expect(loaderRow).toHaveAttribute('aria-rowindex', '51'); + let loaderParentStyles = loaderRow.parentElement.style; + + // 50 items * 25px = 1250 + expect(loaderParentStyles.top).toBe('1250px'); + expect(loaderParentStyles.height).toBe('30px'); + + let sentinel = within(loaderRow.parentElement).getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should not reserve room for the loader if isLoading is false or if gridlist is empty', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(7); + expect(within(gridListTester.gridlist).queryByText('Loading...')).toBeFalsy(); + + let sentinel = within(gridListTester.gridlist).getByTestId('loadMoreSentinel'); + let sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('1250px'); + expect(sentinelParentStyles.height).toBe('0px'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(1); + let emptyStateRow = rows[0]; + expect(emptyStateRow).toHaveTextContent('empty state'); + expect(within(gridListTester.gridlist).queryByText('Loading...')).toBeFalsy(); + + sentinel = within(gridListTester.gridlist).getByTestId('loadMoreSentinel'); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('0px'); + + // Same as above, setting isLoading when gridlist is empty shouldnt change the layout + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(1); + emptyStateRow = rows[0]; + expect(emptyStateRow).toHaveTextContent('empty state'); + expect(within(gridListTester.gridlist).queryByText('Loading...')).toBeFalsy(); + + sentinel = within(gridListTester.gridlist).getByTestId('loadMoreSentinel'); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('0px'); + }); + }); + }); }); From 379fba7b62efbef14107c09949438a27b797f193 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:20:33 -0700 Subject: [PATCH 56/90] fix install? --- packages/@react-spectrum/s2/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index fc7900cbbf0..74c0a29fcda 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -135,8 +135,8 @@ "@react-aria/i18n": "^3.12.8", "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", - "@react-aria/separator": "^3.4.8", "@react-aria/overlays": "^3.27.0", + "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index dfbe9ca4af7..62f07046e46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7943,8 +7943,8 @@ __metadata: "@react-aria/i18n": "npm:^3.12.8" "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" - "@react-aria/separator": "npm:^3.4.8" "@react-aria/overlays": "npm:^3.27.0" + "@react-aria/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" From 160e9a0f8a30c8e37198a5deab866e681719fd0f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:06:37 -0700 Subject: [PATCH 57/90] fix sizes --- packages/@react-spectrum/s2/src/ComboBox.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index fd860465dd0..95b4515b0ef 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -225,9 +225,9 @@ export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ marginX: { size: { S: `[${edgeToText(24)}]`, - M: `[${edgeToText(24)}]`, - L: `[${edgeToText(24)}]`, - XL: `[${edgeToText(24)}]` + M: `[${edgeToText(32)}]`, + L: `[${edgeToText(40)}]`, + XL: `[${edgeToText(48)}]` } } }); @@ -249,9 +249,9 @@ const separatorWrapper = style({ marginX: { size: { S: `[${edgeToText(24)}]`, - M: `[${edgeToText(24)}]`, - L: `[${edgeToText(24)}]`, - XL: `[${edgeToText(24)}]` + M: `[${edgeToText(32)}]`, + L: `[${edgeToText(40)}]`, + XL: `[${edgeToText(48)}]` } }, height: 12 From fac19205cc93652dd843353f9343244b66d9122f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:08:54 -0700 Subject: [PATCH 58/90] fix lint --- packages/@react-spectrum/s2/src/ComboBox.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 95b4515b0ef..bbb69b968ba 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -169,10 +169,8 @@ export let listboxItem = style({ }, paddingBottom: '--labelPadding', backgroundColor: { // TODO: revisit color when I have access to dev mode again - default: { - default: 'transparent', - isFocused: baseColor('gray-100').isFocusVisible - } + default: 'transparent', + isFocused: baseColor('gray-100').isFocusVisible }, color: { default: 'neutral', @@ -242,7 +240,7 @@ export let listboxHeading = style({ // not sure why edgeToText won't work... const separatorWrapper = style({ display: { - ':is(:last-child > &)': 'none', + ':is(:last-child > *)': 'none', default: 'flex' }, // A workaround since edgeToText() returns undefined for some reason From f87438b4c9285e441140c90d30285f882306cf18 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 29 Apr 2025 14:03:33 -0700 Subject: [PATCH 59/90] update ScrollView to fix ComboBox tests the issue was that updateSize was being called before the listboxref was attached due to it being immediatelly called in the test. The change makes it so the updateSize is called in the next render instead --- .../virtualizer/src/ScrollView.tsx | 15 ++- .../picker/test/Picker.test.js | 3 + .../s2/chromatic/Combobox.stories.tsx | 4 + .../@react-spectrum/s2/test/Combobox.test.tsx | 97 +++++++++++++++++++ .../test/ComboBox.test.js | 47 ++++++++- 5 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 packages/@react-spectrum/s2/test/Combobox.test.tsx diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 532dfa59707..716baaa5022 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -98,7 +98,6 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); + let [, setUpdate] = useState({}); + let queuedUpdateSize = useRef(false); useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -209,12 +210,22 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject fn()); + // Queue call of updateSize to happen in a separate render but within the same act so that RAC virtualized ComboBoxes and Selects + // work properly + queuedUpdateSize.current = true; + setUpdate({}); + lastContentSize.current = contentSize; + return; } else { queueMicrotask(() => updateSize(flushSync)); } } + if (queuedUpdateSize.current) { + queuedUpdateSize.current = false; + updateSize(fn => fn()); + } + lastContentSize.current = contentSize; }); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 36421aaf844..81a1c47a9a0 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1025,6 +1025,9 @@ describe('Picker', function () { expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Empty'); + // TODO: this test (along with others in this suite) fails because we seem to be detecting that the button is being focused after the + // dropdown is opened, resulting in the dropdown closing due to useOverlay interactOutside logic + // Seems to specifically happen if the Picker has a selected item and the user tries to open the Picker await selectTester.selectOption({option: 'Zero'}); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenLastCalledWith('0'); diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index e285f1a6c94..f9ae13d20c8 100644 --- a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx @@ -142,5 +142,9 @@ export const Filtering = { await waitFor(() => { expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); }, {timeout: 5000}); + + await waitFor(() => { + expect(within(listbox).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); } }; diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx new file mode 100644 index 00000000000..ab3489c6f33 --- /dev/null +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2025 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 {act, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {ComboBox, ComboBoxItem} from '../src'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; + +describe('Combobox', () => { + let testUtilUser = new User(); + + beforeAll(function () { + jest.useFakeTimers(); + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('should render the sentinel when the combobox is empty', async () => { + let tree = render( + + {[]} + + ); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + expect(comboboxTester.listbox).toBeFalsy(); + comboboxTester.setInteractionType('mouse'); + await comboboxTester.open(); + + expect(comboboxTester.options()).toHaveLength(1); + expect(within(comboboxTester.listbox!).getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + // TODO: will need to update these tests when I replace isLoading with colection in useLoadMoreSentinel + it('should only call loadMore if loading is false', async () => { + let onLoadMore = jest.fn(); + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); + + let tree = render( + + Chocolate + Mint + Strawberry + Vanilla + Chocolate Chip Cookie Dough + + ); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + expect(comboboxTester.listbox).toBeFalsy(); + comboboxTester.setInteractionType('mouse'); + await comboboxTester.open(); + + expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + act(() => {jest.runAllTimers();}); + + tree.rerender( + + Chocolate + Mint + Strawberry + Vanilla + Chocolate Chip Cookie Dough + + ); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + act(() => {jest.runAllTimers();}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 3f5c3fa36df..7fa0f239a22 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -11,7 +11,7 @@ */ import {act} from '@testing-library/react'; -import {Button, ComboBox, ComboBoxContext, FieldError, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Popover, Text} from '../'; +import {Button, ComboBox, ComboBoxContext, FieldError, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, ListLayout, Popover, Text, Virtualizer} from '../'; import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import React from 'react'; import {User} from '@react-aria/test-utils'; @@ -38,8 +38,15 @@ describe('ComboBox', () => { let user; let testUtilUser = new User(); beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + it('provides slots', async () => { let {getByRole} = render(); @@ -295,4 +302,42 @@ describe('ComboBox', () => { expect(queryByRole('listbox')).not.toBeInTheDocument(); }); + + it('should support virtualizer', async () => { + 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 tree = render( + + +
+ + +
+ + + + {(item) => {item.name}} + + + +
+ ); + + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + expect(comboboxTester.listbox).toBeFalsy(); + comboboxTester.setInteractionType('mouse'); + await comboboxTester.open(); + + expect(comboboxTester.options()).toHaveLength(7); + }); }); From 7c5f1e8f0063c5bd3b1539f6161e163d2dbfed1f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 29 Apr 2025 15:56:41 -0700 Subject: [PATCH 60/90] refactor to use collection instead of isLoading in useLoadMoreSentinel this allows us to only have one prop in the sentinel to control visiblility of the loader while still calling loadMore when the collection changes. Also gives the added benefit of causing load more to happen if items are deleted from the collection --- .../utils/src/useLoadMoreSentinel.ts | 22 +++++------ packages/@react-spectrum/s2/src/CardView.tsx | 1 - packages/@react-spectrum/s2/src/ComboBox.tsx | 19 ++++------ packages/@react-spectrum/s2/src/TableView.tsx | 14 +++---- .../s2/stories/ComboBox.stories.tsx | 5 ++- .../s2/stories/Picker.stories.tsx | 5 ++- .../s2/stories/TableView.stories.tsx | 5 ++- .../@react-spectrum/s2/test/Combobox.test.tsx | 7 ++-- .../@react-stately/layout/src/ListLayout.ts | 9 ++--- .../react-aria-components/src/GridList.tsx | 18 ++++++--- .../react-aria-components/src/ListBox.tsx | 16 +++++--- packages/react-aria-components/src/Table.tsx | 18 ++++++--- packages/react-aria-components/src/index.ts | 2 +- .../stories/ComboBox.stories.tsx | 10 ++++- .../stories/GridList.stories.tsx | 24 +++++++++--- .../stories/ListBox.stories.tsx | 17 ++++++--- .../stories/Select.stories.tsx | 12 ++++-- .../stories/Table.stories.tsx | 38 +++++++++++-------- .../test/GridList.test.js | 31 ++++++++------- 19 files changed, 162 insertions(+), 111 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 86aca06c5d1..c2857d22597 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ -import type {AsyncLoadable} from '@react-types/shared'; +import type {AsyncLoadable, Collection, Node} from '@react-types/shared'; import {getScrollParent} from './getScrollParent'; import {RefObject, useRef} from 'react'; import {useEffectEvent} from './useEffectEvent'; import {useLayoutEffect} from './useLayoutEffect'; -export interface LoadMoreSentinelProps extends AsyncLoadable { +export interface LoadMoreSentinelProps extends Omit { + collection: Collection>, /** * The amount of offset from the bottom of your scrollable region that should trigger load more. * Uses a percentage value relative to the scroll body's client height. Load more is then triggered @@ -29,7 +30,7 @@ export interface LoadMoreSentinelProps extends AsyncLoadable { } export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { - let {isLoading, onLoadMore, scrollOffset = 1} = props; + let {collection, onLoadMore, scrollOffset = 1} = props; let sentinelObserver = useRef(null); @@ -37,7 +38,9 @@ export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: // Use "isIntersecting" over an equality check of 0 since it seems like there is cases where // a intersection ratio of 0 can be reported when isIntersecting is actually true for (let entry of entries) { - if (entry.isIntersecting && !isLoading && onLoadMore) { + // Note that this will be called if the collection changes, even if onLoadMore was already called and is being processed. + // Up to user discretion as to how to handle these multiple onLoadMore calls + if (entry.isIntersecting && onLoadMore) { onLoadMore(); } } @@ -45,12 +48,9 @@ export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: useLayoutEffect(() => { if (ref.current) { - // TODO: problem with S2's Table loading spinner. Now that we use the isLoading provided to the sentinel in the layout to adjust the height of the loader, - // we are getting space reserved for the loadMore spinner when doing initial loading and rendering empty state at the same time. We can somewhat fix this by providing isLoading={loadingState === 'loadingMore'} - // which will mean the layout won't reserve space for the loader for initial loads, but that breaks the load more behavior (specifically, auto load more to fill scrollOffset. Scroll to load more seems broken to after initial load). - // We need to tear down and set up a new IntersectionObserver to force a check if the sentinel is "in view", see https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 - // I've actually fixed this via a ListLayout change (TableLayout extends this) where I check "collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader')" - // as well as isLoading, but it feels pretty opinionated/implementation specifc + // Tear down and set up a new IntersectionObserver when the collection changes so that we can properly trigger additional loadMores if there is room for more items + // Need to do this tear down and set up since using a large rootMargin will mean the observer's callback isn't called even when scrolling the item into view beause its visibility hasn't actually changed + // https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21 sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`}); sentinelObserver.current.observe(ref.current); } @@ -60,5 +60,5 @@ export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: sentinelObserver.current.disconnect(); } }; - }, [isLoading, triggerLoadMore, ref, scrollOffset]); + }, [collection, triggerLoadMore, ref, scrollOffset]); } diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index dfcc099d1c8..2ddd6388c08 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -245,7 +245,6 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca let renderer; let cardLoadingSentinel = ( ); diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index d2641df0969..f14f44123f7 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -558,21 +558,16 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps - {loadingState === 'loadingMore' && ( - - )} + ); diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5bc4570cc5d..65e9aefcd8a 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -383,14 +383,12 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T // This is because we don't distinguish between loadingMore and loading in the layout, resulting in a different rect being used to build the body. Perhaps can be considered as a user error // if they pass loadingMore without having any other items in the table. Alternatively, could update the layout so it knows the current loading state. let loadMoreSpinner = ( - - {loadingState === 'loadingMore' && ( -
- -
- )} + +
+ +
); diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 1282724da87..c1b32d913a3 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -223,7 +223,7 @@ const AsyncComboBox = (args: any) => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -251,7 +251,8 @@ export const AsyncComboBoxStory = { render: AsyncComboBox, args: { ...Example.args, - label: 'Star Wars Character Lookup' + label: 'Star Wars Character Lookup', + delay: 50 }, name: 'Async loading combobox', parameters: { diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 481316ae804..ac9e49dc184 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -240,7 +240,7 @@ const AsyncPicker = (args: any) => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -260,7 +260,8 @@ const AsyncPicker = (args: any) => { export const AsyncPickerStory = { render: AsyncPicker, args: { - ...Example.args + ...Example.args, + delay: 50 }, name: 'Async loading picker', parameters: { diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 9cf714e2f87..08d2c7f5f7d 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -428,7 +428,7 @@ const OnLoadMoreTable = (args: any) => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -464,7 +464,8 @@ const OnLoadMoreTable = (args: any) => { export const OnLoadMoreTableStory = { render: OnLoadMoreTable, args: { - ...Example.args + ...Example.args, + delay: 50 }, name: 'async loading table' }; diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index ab3489c6f33..db59e8754c8 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -49,8 +49,7 @@ describe('Combobox', () => { expect(within(comboboxTester.listbox!).getByTestId('loadMoreSentinel')).toBeInTheDocument(); }); - // TODO: will need to update these tests when I replace isLoading with colection in useLoadMoreSentinel - it('should only call loadMore if loading is false', async () => { + it('should only call loadMore whenever intersection is detected', async () => { let onLoadMore = jest.fn(); let observe = jest.fn(); let observer = setupIntersectionObserverMock({ @@ -92,6 +91,8 @@ describe('Combobox', () => { act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); - expect(onLoadMore).toHaveBeenCalledTimes(1); + // Note that if this was using useAsyncList, we'd be shielded from extranous onLoadMore calls but + // we want to leave that to user discretion + expect(onLoadMore).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index f5cac55823f..4937e77607a 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -337,15 +337,12 @@ export class ListLayout exte } protected buildLoader(node: Node, x: number, y: number): LayoutNode { - let collection = this.virtualizer!.collection; - let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); let rect = new Rect(x, y, this.padding, 0); let layoutInfo = new LayoutInfo('loader', node.key, rect); rect.width = this.virtualizer!.contentSize.width - this.padding - x; - // TODO: Kinda gross but we also have to differentiate between isLoading and isLoadingMore so that we dont'reserve room - // for the loadMore loader row when we are performing initial load. Is this too opinionated? Note that users creating their own layouts - // may need to perform similar logic - rect.height = node.props.isLoading && !isEmptyOrLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve + // room for the loader alongside rendering the emptyState + rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; return { layoutInfo, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index af02c0ecd13..9c87edfaa4b 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -497,23 +497,29 @@ function RootDropIndicator() { ); } -export interface GridListLoadingIndicatorProps extends LoadMoreSentinelProps, StyleProps { - children?: ReactNode +export interface GridListLoadingSentinelProps extends Omit, StyleProps { + /** + * The load more spinner to render when loading additional items. + */ + children?: ReactNode, + /** + * Whether or not the loading spinner should be rendered or not. + */ + isLoading?: boolean } -export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingIndicatorProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', function GridListLoadingIndicator(props: GridListLoadingSentinelProps, ref: ForwardedRef, item: Node) { let state = useContext(ListStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo(() => ({ - isLoading, onLoadMore, collection: state?.collection, sentinelRef, scrollOffset - }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + }), [onLoadMore, scrollOffset, state?.collection]); UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ @@ -531,7 +537,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu
- {isLoading && state.collection.size > 1 && renderProps.children && ( + {isLoading && renderProps.children && (
, StyleProps { + /** + * The load more spinner to render when loading additional items. + */ + children?: ReactNode, + /** + * Whether or not the loading spinner should be rendered or not. + */ + isLoading?: boolean } export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', function ListBoxLoadingIndicator(props: ListBoxLoadingSentinelProps, ref: ForwardedRef, item: Node) { @@ -478,12 +485,11 @@ export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', fun let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo(() => ({ - isLoading, onLoadMore, collection: state?.collection, sentinelRef, scrollOffset - }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + }), [onLoadMore, scrollOffset, state?.collection]); UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ ...otherProps, @@ -510,7 +516,7 @@ export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', fun
- {isLoading && state.collection.size > 1 && renderProps.children && ( + {isLoading && renderProps.children && (
, StyleProps { + /** + * The load more spinner to render when loading additional items. + */ + children?: ReactNode, + /** + * Whether or not the loading spinner should be rendered or not. + */ + isLoading?: boolean } -export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', function TableLoadingIndicator(props: TableLoadingIndicatorProps, ref: ForwardedRef, item: Node) { +export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', function TableLoadingIndicator(props: TableLoadingSentinelProps, ref: ForwardedRef, item: Node) { let state = useContext(TableStateContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props; @@ -1360,12 +1367,11 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct let sentinelRef = useRef(null); let memoedLoadMoreProps = useMemo(() => ({ - isLoading, onLoadMore, collection: state?.collection, sentinelRef, scrollOffset - }), [isLoading, onLoadMore, scrollOffset, state?.collection]); + }), [onLoadMore, scrollOffset, state?.collection]); UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); let renderProps = useRenderProps({ @@ -1396,7 +1402,7 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct
- {isLoading && state.collection.size > 1 && renderProps.children && ( + {isLoading && renderProps.children && ( { +export const AsyncVirtualizedDynamicCombobox = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { if (cursor) { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -297,6 +297,12 @@ export const AsyncVirtualizedDynamicCombobox = () => { ); }; +AsyncVirtualizedDynamicCombobox.story = { + args: { + delay: 50 + } +}; + const MyListBoxLoaderIndicator = (props) => { return ( diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 6b7362f2d46..2f246ab46a8 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -233,14 +233,14 @@ const MyGridListLoaderIndicator = (props) => { ); }; -export const AsyncGridList = () => { +export const AsyncGridList = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { if (cursor) { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); @@ -262,19 +262,25 @@ export const AsyncGridList = () => { {item.name} )} - + ); }; -export const AsyncGridListVirtualized = () => { +AsyncGridList.story = { + args: { + delay: 50 + } +}; + +export const AsyncGridListVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor, filterText}) { if (cursor) { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); return { @@ -299,12 +305,18 @@ export const AsyncGridListVirtualized = () => { {item => {item.name}} - + ); }; +AsyncGridListVirtualized.story = { + args: { + delay: 50 + } +}; + export function TagGroupInsideGridList() { return ( { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); return { @@ -510,14 +510,15 @@ export const AsyncListBox = (args) => { )} - + ); }; AsyncListBox.story = { args: { - orientation: 'horizontal' + orientation: 'horizontal', + delay: 50 }, argTypes: { orientation: { @@ -534,7 +535,7 @@ export const AsyncListBoxVirtualized = (args) => { cursor = cursor.replace(/^http:\/\//i, 'https://'); } - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal}); let json = await res.json(); return { @@ -580,8 +581,14 @@ export const AsyncListBoxVirtualized = (args) => { )} - + ); }; + +AsyncListBoxVirtualized.story = { + args: { + delay: 50 + } +}; diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 5127dfca452..9b42c5ba954 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -119,7 +119,7 @@ const MyListBoxLoaderIndicator = (props) => { ); }; -export const AsyncVirtualizedCollectionRenderSelect = () => { +export const AsyncVirtualizedCollectionRenderSelect = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -127,7 +127,7 @@ export const AsyncVirtualizedCollectionRenderSelect = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -161,10 +161,16 @@ export const AsyncVirtualizedCollectionRenderSelect = () => { {item.name} )} - + ); }; + +AsyncVirtualizedCollectionRenderSelect.story = { + args: { + delay: 50 + } +}; diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 61e15e33064..3bbeaddfd0c 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -590,7 +590,7 @@ function MyRow(props) { <> {/* Note that all the props are propagated from MyRow to Row, ensuring the id propagates */} - {props.isLoadingMore && } + ); } @@ -673,7 +673,7 @@ interface Character { birth_year: number } -const OnLoadMoreTable = () => { +const OnLoadMoreTable = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -681,7 +681,7 @@ const OnLoadMoreTable = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); @@ -692,8 +692,6 @@ const OnLoadMoreTable = () => { } }); - let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; - return (
@@ -706,7 +704,7 @@ const OnLoadMoreTable = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading', tableWidth: 400})} - isLoading={isLoading} + isLoading={list.loadingState === 'loadingMore'} onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -725,7 +723,10 @@ const OnLoadMoreTable = () => { export const OnLoadMoreTableStory = { render: OnLoadMoreTable, - name: 'onLoadMore table' + name: 'onLoadMore table', + args: { + delay: 50 + } }; export function VirtualizedTable() { @@ -843,6 +844,7 @@ function VirtualizedTableWithEmptyState(args) { Baz renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> @@ -869,7 +871,7 @@ export const VirtualizedTableWithEmptyStateStory = { name: 'Virtualized Table With Empty State' }; -const OnLoadMoreTableVirtualized = () => { +const OnLoadMoreTableVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -877,7 +879,7 @@ const OnLoadMoreTableVirtualized = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -887,7 +889,6 @@ const OnLoadMoreTableVirtualized = () => { } }); - let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; return ( { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoading={isLoading} + isLoading={list.loadingState === 'loadingMore'} onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -924,10 +925,13 @@ const OnLoadMoreTableVirtualized = () => { export const OnLoadMoreTableStoryVirtualized = { render: OnLoadMoreTableVirtualized, - name: 'Virtualized Table with async loading' + name: 'Virtualized Table with async loading', + args: { + delay: 50 + } }; -const OnLoadMoreTableVirtualizedResizeWrapper = () => { +const OnLoadMoreTableVirtualizedResizeWrapper = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -935,7 +939,7 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, args.delay)); let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal}); let json = await res.json(); return { @@ -945,7 +949,6 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { } }); - let isLoading = list.loadingState === 'loading' || list.loadingState === 'loadingMore'; return ( { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoading={isLoading} + isLoading={list.loadingState === 'loadingMore'} onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -985,6 +988,9 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { export const OnLoadMoreTableVirtualizedResizeWrapperStory = { render: OnLoadMoreTableVirtualizedResizeWrapper, name: 'Virtualized Table with async loading, with wrapper around Virtualizer', + args: { + delay: 50 + }, parameters: { description: { data: 'This table has a ResizableTableContainer wrapper around the Virtualizer. The table itself doesnt have any resizablity, this is simply to test that it still loads/scrolls in this configuration.' diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 0a4db267a6a..a093a040e5b 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -920,7 +920,7 @@ describe('GridList', () => { expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); }); - it('should properly render the renderEmptyState if gridlist is empty, even when loading', async () => { + it('should properly render the renderEmptyState if gridlist is empty', async () => { let tree = render(); let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); @@ -930,15 +930,16 @@ describe('GridList', () => { expect(tree.queryByText('Loading...')).toBeFalsy(); expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + // Even if the gridlist is empty, providing isLoading will render the loader tree.rerender(); rows = gridListTester.rows; - expect(rows).toHaveLength(1); - expect(rows[0]).toHaveTextContent('empty state'); - expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(rows).toHaveLength(2); + expect(rows[1]).toHaveTextContent('empty state'); + expect(tree.queryByText('Loading...')).toBeTruthy(); expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); }); - it('should only fire loadMore when not loading and intersection is detected', async () => { + it('should only fire loadMore when intersection is detected regardless of loading state', async () => { let observer = setupIntersectionObserverMock({ observe }); @@ -950,15 +951,15 @@ describe('GridList', () => { expect(onLoadMore).toHaveBeenCalledTimes(0); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); - expect(onLoadMore).toHaveBeenCalledTimes(0); + expect(onLoadMore).toHaveBeenCalledTimes(1); tree.rerender(); expect(observe).toHaveBeenCalledTimes(3); expect(observe).toHaveBeenLastCalledWith(sentinel); - expect(onLoadMore).toHaveBeenCalledTimes(0); + expect(onLoadMore).toHaveBeenCalledTimes(1); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); - expect(onLoadMore).toHaveBeenCalledTimes(1); + expect(onLoadMore).toHaveBeenCalledTimes(2); }); describe('virtualized', () => { @@ -1023,7 +1024,7 @@ describe('GridList', () => { expect(sentinel.parentElement).toHaveAttribute('inert'); }); - it('should not reserve room for the loader if isLoading is false or if gridlist is empty', async () => { + it('should not reserve room for the loader if isLoading is false', async () => { let tree = render(); let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); @@ -1049,18 +1050,20 @@ describe('GridList', () => { expect(sentinelParentStyles.top).toBe('0px'); expect(sentinelParentStyles.height).toBe('0px'); - // Same as above, setting isLoading when gridlist is empty shouldnt change the layout + // Setting isLoading will render the loader even if the list is empty. tree.rerender(); rows = gridListTester.rows; - expect(rows).toHaveLength(1); - emptyStateRow = rows[0]; + expect(rows).toHaveLength(2); + emptyStateRow = rows[1]; expect(emptyStateRow).toHaveTextContent('empty state'); - expect(within(gridListTester.gridlist).queryByText('Loading...')).toBeFalsy(); + + let loadingRow = rows[0]; + expect(loadingRow).toHaveTextContent('Loading...'); sentinel = within(gridListTester.gridlist).getByTestId('loadMoreSentinel'); sentinelParentStyles = sentinel.parentElement.parentElement.style; expect(sentinelParentStyles.top).toBe('0px'); - expect(sentinelParentStyles.height).toBe('0px'); + expect(sentinelParentStyles.height).toBe('30px'); }); }); }); From 63db21ee3ff3b2b6cecb981e62be3e3f25ba335d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Apr 2025 13:31:47 -0700 Subject: [PATCH 61/90] update getItemCount so it doesnt include loaders in custom announcements --- .../@react-spectrum/s2/test/Combobox.test.tsx | 29 ++++++++++++++++++- .../collections/src/getItemCount.ts | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index db59e8754c8..7047d0f93df 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -10,18 +10,23 @@ * governing permissions and limitations under the License. */ -import {act, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +jest.mock('@react-aria/live-announcer'); +import {act, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {announce} from '@react-aria/live-announcer'; import {ComboBox, ComboBoxItem} from '../src'; import React from 'react'; import {User} from '@react-aria/test-utils'; +import userEvent from '@testing-library/user-event'; describe('Combobox', () => { + let user; let testUtilUser = new User(); beforeAll(function () { jest.useFakeTimers(); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + user = userEvent.setup({delay: null, pointerMap}); }); afterEach(() => { @@ -95,4 +100,26 @@ describe('Combobox', () => { // we want to leave that to user discretion expect(onLoadMore).toHaveBeenCalledTimes(2); }); + + it('should omit the laoder from the count of items', async () => { + jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'MacIntel'); + let tree = render( + + Chocolate + Mint + Strawberry + Vanilla + Chocolate Chip Cookie Dough + + ); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container, interactionType: 'mouse'}); + await comboboxTester.open(); + + expect(announce).toHaveBeenLastCalledWith('5 options available.'); + expect(within(comboboxTester.listbox!).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + + await user.keyboard('C'); + expect(announce).toHaveBeenLastCalledWith('2 options available.'); + }); }); diff --git a/packages/@react-stately/collections/src/getItemCount.ts b/packages/@react-stately/collections/src/getItemCount.ts index e1f892be544..dfcd95dba9a 100644 --- a/packages/@react-stately/collections/src/getItemCount.ts +++ b/packages/@react-stately/collections/src/getItemCount.ts @@ -27,7 +27,7 @@ export function getItemCount(collection: Collection>): number { for (let item of items) { if (item.type === 'section') { countItems(getChildNodes(item, collection)); - } else { + } else if (item.type === 'item') { counter++; } } From 1eb421eb25180ac3973656dea60622edf6716deb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Apr 2025 14:03:57 -0700 Subject: [PATCH 62/90] make sure listbox doesnt add extranous padding above the empty state when empty or loading since we are now rendering the virtualizer body if there is a loading sentinel, we dont want to add padding to the body rect calc since that will push the renderEmpty node down. Not a problem in TableLayout it seems --- packages/@react-spectrum/s2/src/ComboBox.tsx | 7 +------ packages/@react-stately/layout/src/ListLayout.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index f14f44123f7..3052aaf46b1 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -590,8 +590,6 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps ); } - - let isEmptyOrLoading = state?.collection?.size === 0 || (state?.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!)!.type === 'loader'); let scale = useScale(); return ( @@ -689,10 +687,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 4937e77607a..008bdcad8de 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -255,9 +255,13 @@ export class ListLayout exte let collection = this.virtualizer!.collection; let skipped = 0; let nodes: LayoutNode[] = []; + let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); + if (isEmptyOrLoading) { + y = 0; + } + for (let node of collection) { let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; - // Skip rows before the valid rectangle unless they are already cached. if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { y += rowHeight; @@ -289,7 +293,7 @@ export class ListLayout exte } y -= this.gap; - y += this.padding; + y += isEmptyOrLoading ? 0 : this.padding; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); return nodes; } From 15bc7d1f1a5eb1341bafe4670e964a7d8b004e8f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Apr 2025 17:32:38 -0700 Subject: [PATCH 63/90] add listbox and table tests --- .../test/ListBox.test.js | 279 ++++++++++++++++-- .../react-aria-components/test/Table.test.js | 219 +++++++++----- 2 files changed, 397 insertions(+), 101 deletions(-) diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 10b9370a4e7..a8271eec50b 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -10,9 +10,11 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import { - Button, Dialog, + Button, + Collection, + Dialog, DialogTrigger, DropIndicator, Header, Heading, @@ -27,6 +29,7 @@ import { Virtualizer } from '../'; import React, {useState} from 'react'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -899,28 +902,28 @@ describe('ListBox', () => { fireEvent.keyUp(option, {key: 'Enter'}); act(() => jest.runAllTimers()); - let rows = getAllByRole('option'); - expect(rows).toHaveLength(4); - expect(rows[0]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[0]).toHaveAttribute('data-drop-target', 'true'); - expect(rows[0]).toHaveAttribute('aria-label', 'Insert before Cat'); - expect(rows[0]).toHaveTextContent('Test'); - expect(rows[1]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[1]).not.toHaveAttribute('data-drop-target'); - expect(rows[1]).toHaveAttribute('aria-label', 'Insert between Cat and Dog'); - expect(rows[2]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[2]).not.toHaveAttribute('data-drop-target'); - expect(rows[2]).toHaveAttribute('aria-label', 'Insert between Dog and Kangaroo'); - expect(rows[3]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[3]).not.toHaveAttribute('data-drop-target'); - expect(rows[3]).toHaveAttribute('aria-label', 'Insert after Kangaroo'); + let options = getAllByRole('option'); + expect(options).toHaveLength(4); + expect(options[0]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(options[0]).toHaveAttribute('data-drop-target', 'true'); + expect(options[0]).toHaveAttribute('aria-label', 'Insert before Cat'); + expect(options[0]).toHaveTextContent('Test'); + expect(options[1]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(options[1]).not.toHaveAttribute('data-drop-target'); + expect(options[1]).toHaveAttribute('aria-label', 'Insert between Cat and Dog'); + expect(options[2]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(options[2]).not.toHaveAttribute('data-drop-target'); + expect(options[2]).toHaveAttribute('aria-label', 'Insert between Dog and Kangaroo'); + expect(options[3]).toHaveAttribute('class', 'react-aria-DropIndicator'); + expect(options[3]).not.toHaveAttribute('data-drop-target'); + expect(options[3]).toHaveAttribute('aria-label', 'Insert after Kangaroo'); fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Cat and Dog'); - expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); - expect(rows[1]).toHaveAttribute('data-drop-target', 'true'); + expect(options[0]).not.toHaveAttribute('data-drop-target', 'true'); + expect(options[1]).toHaveAttribute('data-drop-target', 'true'); fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); @@ -929,7 +932,7 @@ describe('ListBox', () => { expect(onReorder).toHaveBeenCalledTimes(1); }); - it('should support dropping on rows', () => { + it('should support dropping on options', () => { let onItemDrop = jest.fn(); let {getAllByRole} = render(<> @@ -942,13 +945,13 @@ describe('ListBox', () => { act(() => jest.runAllTimers()); let listboxes = getAllByRole('listbox'); - let rows = within(listboxes[1]).getAllByRole('option'); - expect(rows).toHaveLength(3); - expect(rows[0]).toHaveAttribute('data-drop-target', 'true'); - expect(rows[1]).not.toHaveAttribute('data-drop-target'); - expect(rows[2]).not.toHaveAttribute('data-drop-target'); + let options = within(listboxes[1]).getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options[0]).toHaveAttribute('data-drop-target', 'true'); + expect(options[1]).not.toHaveAttribute('data-drop-target'); + expect(options[2]).not.toHaveAttribute('data-drop-target'); - expect(document.activeElement).toBe(rows[0]); + expect(document.activeElement).toBe(options[0]); fireEvent.keyDown(document.activeElement, {key: 'Enter'}); fireEvent.keyUp(document.activeElement, {key: 'Enter'}); @@ -1297,9 +1300,9 @@ describe('ListBox', () => { let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange}); let items = getAllByRole('option'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -1309,9 +1312,9 @@ describe('ListBox', () => { let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); let items = getAllByRole('option'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -1321,11 +1324,223 @@ describe('ListBox', () => { let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); let items = getAllByRole('option'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); }); + + describe('async loading', () => { + let items = [ + {name: 'Foo'}, + {name: 'Bar'}, + {name: 'Baz'} + ]; + let renderEmptyState = () => { + return ( +
empty state
+ ); + }; + let AsyncListbox = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + ); + }; + + let onLoadMore = jest.fn(); + let observe = jest.fn(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the loading element when loading', async () => { + let tree = render(); + + let listboxTester = testUtilUser.createTester('ListBox', {root: tree.getByRole('listbox')}); + let options = listboxTester.options(); + expect(options).toHaveLength(4); + let loaderRow = options[3]; + expect(loaderRow).toHaveTextContent('Loading...'); + + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should render the sentinel but not the loading indicator when not loading', async () => { + let tree = render(); + + let listboxTester = testUtilUser.createTester('ListBox', {root: tree.getByRole('listbox')}); + let options = listboxTester.options(); + expect(options).toHaveLength(3); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should properly render the renderEmptyState if listbox is empty', async () => { + let tree = render(); + + let listboxTester = testUtilUser.createTester('ListBox', {root: tree.getByRole('listbox')}); + let options = listboxTester.options(); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('empty state'); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + + // Even if the listbox is empty, providing isLoading will render the loader + tree.rerender(); + options = listboxTester.options(); + expect(options).toHaveLength(2); + expect(options[1]).toHaveTextContent('empty state'); + expect(tree.queryByText('Loading...')).toBeTruthy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should only fire loadMore when intersection is detected regardless of loading state', async () => { + let observer = setupIntersectionObserverMock({ + observe + }); + + let tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + + tree.rerender(); + expect(observe).toHaveBeenCalledTimes(3); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(1); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(2); + }); + + describe('virtualized', () => { + let items = []; + for (let i = 0; i < 50; i++) { + items.push({name: 'Foo' + i}); + } + let clientWidth, clientHeight; + + beforeAll(() => { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + beforeEach(() => { + act(() => {jest.runAllTimers();}); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let VirtualizedAsyncListbox = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + + ); + }; + + it('should always render the sentinel even when virtualized', () => { + let tree = render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: tree.getByRole('listbox')}); + let options = listboxTester.options(); + expect(options).toHaveLength(8); + let loaderRow = options[7]; + expect(loaderRow).toHaveTextContent('Loading...'); + expect(loaderRow).toHaveAttribute('aria-posinset', '51'); + expect(loaderRow).toHaveAttribute('aria-setSize', '51'); + let loaderParentStyles = loaderRow.parentElement.style; + + // 50 items * 25px = 1250 + expect(loaderParentStyles.top).toBe('1250px'); + expect(loaderParentStyles.height).toBe('30px'); + + let sentinel = within(loaderRow.parentElement).getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + // TODO: for some reason this tree renders empty if ran with the above test... + // It thinks that the contextSize is 0 and never updates + it.skip('should not reserve room for the loader if isLoading is false', () => { + let tree = render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: tree.getByRole('listbox')}); + let options = listboxTester.options(); + expect(options).toHaveLength(7); + expect(within(listboxTester.listbox).queryByText('Loading...')).toBeFalsy(); + + let sentinel = within(listboxTester.listbox).getByTestId('loadMoreSentinel'); + let sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('1250px'); + expect(sentinelParentStyles.height).toBe('0px'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + + tree.rerender(); + options = listboxTester.options(); + expect(options).toHaveLength(1); + let emptyStateRow = options[0]; + expect(emptyStateRow).toHaveTextContent('empty state'); + expect(within(listboxTester.listbox).queryByText('Loading...')).toBeFalsy(); + + sentinel = within(listboxTester.listbox).getByTestId('loadMoreSentinel'); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('0px'); + + // Setting isLoading will render the loader even if the list is empty. + tree.rerender(); + options = listboxTester.options(); + expect(options).toHaveLength(2); + emptyStateRow = options[1]; + expect(emptyStateRow).toHaveTextContent('empty state'); + + let loadingRow = options[0]; + expect(loadingRow).toHaveTextContent('Loading...'); + + sentinel = within(listboxTester.listbox).getByTestId('loadMoreSentinel'); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('30px'); + }); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 9bdd3e3840b..db001932bda 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1843,7 +1843,7 @@ describe('Table', () => { Foo Bar - + 'No results'}> {(item) => ( @@ -1865,58 +1865,86 @@ describe('Table', () => { onLoadMore.mockRestore(); }); - it('should fire onLoadMore when scrolling near the bottom', function () { + it('should render the loading element when loading', async () => { + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(12); + let loaderRow = rows[11]; + expect(loaderRow).toHaveTextContent('spinner'); + + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should render the sentinel but not the loading indicator when not loading', async () => { + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(11); + expect(tree.queryByText('spinner')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should properly render the renderEmptyState if table is empty', async () => { + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(2); + expect(rows[1]).toHaveTextContent('No results'); + expect(tree.queryByText('spinner')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + + // Even if the table is empty, providing isLoading will render the loader + tree.rerender(); + rows = tableTester.rows; + expect(rows).toHaveLength(3); + expect(rows[2]).toHaveTextContent('No results'); + expect(tree.queryByText('spinner')).toBeTruthy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should fire onLoadMore when intersecting with the sentinel', function () { let observe = jest.fn(); let observer = setupIntersectionObserverMock({ observe }); - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); - jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); let tree = render(); - - let scrollView = tree.getByTestId('scrollRegion'); expect(onLoadMore).toHaveBeenCalledTimes(0); let sentinel = tree.getByTestId('loadMoreSentinel'); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(sentinel.nodeName).toBe('TD'); - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - expect(onLoadMore).toHaveBeenCalledTimes(0); - - scrollView.scrollTop = 76; - fireEvent.scroll(scrollView); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); - it('doesn\'t call onLoadMore if it is already loading items', function () { - let observer = setupIntersectionObserverMock(); - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); - jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); - - let tree = render(); + it('should only fire loadMore when intersection is detected regardless of loading state', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); - let scrollView = tree.getByTestId('scrollRegion'); + let tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(0); - scrollView.scrollTop = 76; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - expect(onLoadMore).toHaveBeenCalledTimes(0); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); - tree.rerender(); + tree.rerender(); + expect(observe).toHaveBeenCalledTimes(3); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(1); - fireEvent.scroll(scrollView); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); - act(() => {jest.runAllTimers();}); - expect(onLoadMore).toHaveBeenCalledTimes(1); + expect(onLoadMore).toHaveBeenCalledTimes(2); }); it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () { @@ -1993,69 +2021,122 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(1); }); - it('works with virtualizer', function () { - let observe = jest.fn(); - let observer = setupIntersectionObserverMock({ - observe - }); + describe('virtualized', () => { let items = []; - for (let i = 0; i < 6; i++) { + for (let i = 1; i <= 50; i++) { items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); } - function VirtualizedTableLoad() { + let clientWidth, clientHeight; + + beforeAll(() => { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + // jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 150); + // jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + // jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementationOnce(() => 0).mockImplementation(function () { + // if (this.getAttribute('role') === 'grid') { + // return 50; + // } + + // return 25; + // }); + }); + + beforeEach(() => { + act(() => {jest.runAllTimers();}); + }); + + afterAll(function () { + // TODO fix these + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let VirtualizedTableLoad = (props) => { + let {items, isLoading, onLoadMore} = props; + return ( - +
Foo Bar - + 'No results'}> - {item => ( + {(item) => ( {item.foo} {item.bar} )} - + +
spinner
+
); - } - - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 150); - jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); - jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementationOnce(() => 0).mockImplementation(function () { - if (this.getAttribute('role') === 'grid') { - return 50; - } + }; - return 25; + it('should always render the sentinel even when virtualized', () => { + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(7); + let loaderRow = rows[6]; + expect(loaderRow).toHaveTextContent('spinner'); + expect(loaderRow).toHaveAttribute('aria-rowindex', '52'); + let loaderParentStyles = loaderRow.parentElement.style; + + // 50 items * 25px = 1250 + expect(loaderParentStyles.top).toBe('1250px'); + expect(loaderParentStyles.height).toBe('30px'); + + let sentinel = within(loaderRow.parentElement).getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); }); - let {getByRole, getByTestId} = render(); - - let scrollView = getByRole('grid'); - expect(onLoadMore).toHaveBeenCalledTimes(0); - let sentinel = getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenLastCalledWith(sentinel); - expect(sentinel.nodeName).toBe('DIV'); - - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - expect(onLoadMore).toHaveBeenCalledTimes(0); - - scrollView.scrollTop = 76; - fireEvent.scroll(scrollView); - act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); - act(() => {jest.runAllTimers();}); - - expect(onLoadMore).toHaveBeenCalledTimes(1); + it('should not reserve room for the loader if isLoading is false', () => { + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(6); + expect(within(tableTester.table).queryByText('spinner')).toBeFalsy(); + + let sentinel = within(tableTester.table).getByTestId('loadMoreSentinel'); + let sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('1250px'); + expect(sentinelParentStyles.height).toBe('0px'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + + tree.rerender(); + rows = tableTester.rows; + expect(rows).toHaveLength(1); + let emptyStateRow = rows[0]; + expect(emptyStateRow).toHaveTextContent('No results'); + expect(within(tableTester.table).queryByText('spinner')).toBeFalsy(); + sentinel = within(tableTester.table).getByTestId('loadMoreSentinel', {hidden: true}); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('0px'); + + // Setting isLoading will render the loader even if the table is empty. + tree.rerender(); + rows = tableTester.rows; + expect(rows).toHaveLength(2); + emptyStateRow = rows[1]; + expect(emptyStateRow).toHaveTextContent('No results'); + + let loadingRow = rows[0]; + expect(loadingRow).toHaveTextContent('spinner'); + + sentinel = within(tableTester.table).getByTestId('loadMoreSentinel', {hidden: true}); + sentinelParentStyles = sentinel.parentElement.parentElement.style; + expect(sentinelParentStyles.top).toBe('0px'); + expect(sentinelParentStyles.height).toBe('30px'); + }); }); }); From 77b4b62ff441c077ef8ab6610759d77d09fca16f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 1 May 2025 11:01:06 -0700 Subject: [PATCH 64/90] fix delay when opening many items S2 select --- packages/@react-spectrum/s2/src/Picker.tsx | 179 ++++++++++++--------- 1 file changed, 103 insertions(+), 76 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index f3bbcf9e1a1..3dbead9b0eb 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -53,7 +53,7 @@ import { FieldLabel, HelpText } from './Field'; -import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; +import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; @@ -278,21 +278,6 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick menuOffset = 8; } - // For mouse interactions, pickers open on press start. When the popover underlay appears - // it covers the trigger button, causing onPressEnd to fire immediately and no press scaling - // to occur. We override this by listening for pointerup on the document ourselves. - let [isPressed, setPressed] = useState(false); - let {addGlobalListener} = useGlobalListeners(); - let onPressStart = (e: PressEvent) => { - if (e.pointerType !== 'mouse') { - return; - } - setPressed(true); - addGlobalListener(document, 'pointerup', () => { - setPressed(false); - }, {once: true, capture: true}); - }; - return ( {label} - - - + { + buttonRef: RefObject +} + +function PickerButton(props: PickerButtonInnerProps) { + let { + isOpen, + isQuiet, + isFocusVisible, + size, + isInvalid, + isDisabled, + buttonRef + } = props; + + // For mouse interactions, pickers open on press start. When the popover underlay appears + // it covers the trigger button, causing onPressEnd to fire immediately and no press scaling + // to occur. We override this by listening for pointerup on the document ourselves. + let [isPressed, setPressed] = useState(false); + let {addGlobalListener} = useGlobalListeners(); + let onPressStart = (e: PressEvent) => { + if (e.pointerType !== 'mouse') { + return; + } + setPressed(true); + addGlobalListener(document, 'pointerup', () => { + setPressed(false); + }, {once: true, capture: true}); + }; + + return ( + + + + ); +} + export interface PickerItemProps extends Omit, StyleProps { children: ReactNode } From 1bbd69c060039899c222c6ca97476752fed04a7b Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 1 May 2025 16:54:55 -0700 Subject: [PATCH 65/90] sorta get selected item to scroll into view virtualized --- packages/@react-spectrum/s2/src/TableView.tsx | 3 +++ packages/@react-stately/layout/src/GridLayout.ts | 2 +- packages/@react-stately/layout/src/ListLayout.ts | 2 +- packages/@react-stately/layout/src/TableLayout.ts | 6 +++++- packages/@react-stately/virtualizer/src/Rect.ts | 4 +++- packages/@react-stately/virtualizer/src/Virtualizer.ts | 3 +-- packages/react-aria-components/src/Virtualizer.tsx | 4 ---- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 80953147990..91b18ce3204 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -194,6 +194,9 @@ export class S2TableLayout extends TableLayout { protected buildCollection(): LayoutNode[] { let [header, body] = super.buildCollection(); + if (!header) { + return []; + } let {children, layoutInfo} = body; // TableLayout's buildCollection always sets the body width to the max width between the header width, but // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 14c8e0865b4..11c9e3d72c8 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -227,7 +227,7 @@ export class GridLayout exte // Find the closest item within on either side of the point using the gap width. let key: Key | null = null; if (this.numColumns === 1) { - let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, this.gap.height * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, Math.max(1, this.gap.height * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let minDistance = Infinity; for (let candidate of candidates) { diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..beb9c1f2996 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -506,7 +506,7 @@ export class ListLayout exte y += this.virtualizer!.visibleRect.y; // Find the closest item within on either side of the point using the gap width. - let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let key: Key | null = null; let minDistance = Infinity; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 4c820b5b1e9..68a92183a68 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -85,6 +85,10 @@ export class TableLayout exten this.stickyColumnIndices = []; let collection = this.virtualizer!.collection as TableCollection; + if (collection.head?.key === -1) { + return []; + } + for (let column of collection.columns) { // The selection cell and any other sticky columns always need to be visible. // In addition, row headers need to be in the DOM for accessibility labeling. @@ -543,7 +547,7 @@ export class TableLayout exten y += this.virtualizer!.visibleRect.y; // Find the closest item within on either side of the point using the gap width. - let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let key: Key | null = null; let minDistance = Infinity; diff --git a/packages/@react-stately/virtualizer/src/Rect.ts b/packages/@react-stately/virtualizer/src/Rect.ts index 58a74d0903d..65b55c194fb 100644 --- a/packages/@react-stately/virtualizer/src/Rect.ts +++ b/packages/@react-stately/virtualizer/src/Rect.ts @@ -92,7 +92,9 @@ export class Rect { * @param rect - The rectangle to check. */ intersects(rect: Rect): boolean { - return this.x <= rect.x + rect.width + return this.area > 0 + && rect.area > 0 + && this.x <= rect.x + rect.width && rect.x <= this.x + this.width && this.y <= rect.y + rect.height && rect.y <= this.y + this.height; diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index c627658a0bd..09e660dd5a7 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -193,8 +193,7 @@ export class Virtualizer { } else { rect = this._overscanManager.getOverscannedRect(); } - - let layoutInfos = rect.area === 0 ? [] : this.layout.getVisibleLayoutInfos(rect); + let layoutInfos = this.layout.getVisibleLayoutInfos(rect); let map = new Map; for (let layoutInfo of layoutInfos) { map.set(layoutInfo.key, layoutInfo); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index affe32c2442..c8eabc015a0 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -102,10 +102,6 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat onScrollEnd: state.endScrolling }, scrollRef!); - if (state.contentSize.area === 0) { - return null; - } - return (
From bebc44bdf5b6cce068701ed1ffae25e4ec3411e7 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 1 May 2025 16:55:05 -0700 Subject: [PATCH 66/90] fix tests --- .../autocomplete/test/SearchAutocomplete.test.js | 2 ++ packages/@react-spectrum/card/test/CardView.test.js | 5 +++++ packages/@react-spectrum/color/test/ColorPicker.test.js | 5 +++++ packages/@react-spectrum/combobox/test/ComboBox.test.js | 2 ++ packages/@react-spectrum/picker/test/Picker.test.js | 7 ++++++- packages/@react-spectrum/picker/test/TempUtilTest.test.js | 5 +++++ packages/@react-spectrum/tabs/test/Tabs.test.js | 1 + 7 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 553c7c38f6c..a94c20bea0c 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -143,6 +143,8 @@ describe('SearchAutocomplete', function () { user = userEvent.setup({delay: null, pointerMap}); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); simulateDesktop(); jest.useFakeTimers(); diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index 9ed6a8df3e9..a05d69d963f 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -141,9 +141,14 @@ describe('CardView', function () { user = userEvent.setup({delay: null, pointerMap}); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => mockWidth); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => mockHeight); + // jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); jest.useFakeTimers(); }); + beforeEach(() => { + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + }) + afterEach(() => { jest.clearAllMocks(); act(() => jest.runAllTimers()); diff --git a/packages/@react-spectrum/color/test/ColorPicker.test.js b/packages/@react-spectrum/color/test/ColorPicker.test.js index a22b63aa124..51a930761f7 100644 --- a/packages/@react-spectrum/color/test/ColorPicker.test.js +++ b/packages/@react-spectrum/color/test/ColorPicker.test.js @@ -22,9 +22,14 @@ describe('ColorPicker', function () { beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); jest.useFakeTimers(); }); + afterAll(function () { + jest.restoreAllMocks(); + }); + afterEach(() => { act(() => {jest.runAllTimers();}); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 4b97d4c810d..3c0401ceb7b 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -257,6 +257,7 @@ describe('ComboBox', function () { }); beforeEach(() => { + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); load = jest .fn() .mockImplementationOnce(getFilterItems) @@ -3667,6 +3668,7 @@ describe('ComboBox', function () { describe('mobile combobox', function () { beforeEach(() => { simulateMobile(); + }); afterEach(() => { diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 36421aaf844..be16a4b0036 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -31,7 +31,7 @@ import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; describe('Picker', function () { - let offsetWidth, offsetHeight; + let offsetWidth, offsetHeight, scrollHeight; let onSelectionChange = jest.fn(); let testUtilUser = new User(); let user; @@ -40,6 +40,7 @@ describe('Picker', function () { user = userEvent.setup({delay: null, pointerMap}); offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); simulateDesktop(); jest.useFakeTimers(); }); @@ -47,6 +48,7 @@ describe('Picker', function () { afterAll(function () { offsetWidth.mockReset(); offsetHeight.mockReset(); + scrollHeight.mockReset(); }); afterEach(() => { @@ -91,6 +93,9 @@ describe('Picker', function () { expect(queryByRole('listbox')).toBeNull(); let picker = selectTester.trigger; + // let picker = getByRole('button'); + // await user.click(picker); + // act(() => jest.runAllTimers()); await selectTester.open(); let listbox = selectTester.listbox; diff --git a/packages/@react-spectrum/picker/test/TempUtilTest.test.js b/packages/@react-spectrum/picker/test/TempUtilTest.test.js index a9fbc25eba4..0e135d7f4a5 100644 --- a/packages/@react-spectrum/picker/test/TempUtilTest.test.js +++ b/packages/@react-spectrum/picker/test/TempUtilTest.test.js @@ -27,9 +27,14 @@ describe('Picker/Select ', function () { beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); simulateDesktop(); }); + afterAll(function () { + jest.restoreAllMocks(); + }); + describe('with real timers', function () { beforeAll(function () { jest.useRealTimers(); diff --git a/packages/@react-spectrum/tabs/test/Tabs.test.js b/packages/@react-spectrum/tabs/test/Tabs.test.js index 3178a266774..802c7701e79 100644 --- a/packages/@react-spectrum/tabs/test/Tabs.test.js +++ b/packages/@react-spectrum/tabs/test/Tabs.test.js @@ -60,6 +60,7 @@ describe('Tabs', function () { beforeEach(() => { jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); }); From 9976091ec6e9a26b0b2e2d4a85587bf91c17f980 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 1 May 2025 16:58:36 -0700 Subject: [PATCH 67/90] cleanup fix lint --- .../autocomplete/test/SearchAutocomplete.test.js | 2 +- packages/@react-spectrum/card/test/CardView.test.js | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index a94c20bea0c..141f9a33ca1 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -144,7 +144,7 @@ describe('SearchAutocomplete', function () { jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); - scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); simulateDesktop(); jest.useFakeTimers(); diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index a05d69d963f..41260e2fa54 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -141,13 +141,12 @@ describe('CardView', function () { user = userEvent.setup({delay: null, pointerMap}); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => mockWidth); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => mockHeight); - // jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); jest.useFakeTimers(); }); beforeEach(() => { jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); - }) + }); afterEach(() => { jest.clearAllMocks(); From 9c51d0decb0d21707a568691ee4c3b9f681ffe78 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 1 May 2025 17:03:44 -0700 Subject: [PATCH 68/90] more cleanup --- .../autocomplete/test/SearchAutocomplete.test.js | 1 - packages/@react-spectrum/combobox/test/ComboBox.test.js | 1 - packages/@react-spectrum/picker/test/Picker.test.js | 3 --- 3 files changed, 5 deletions(-) diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 141f9a33ca1..4b2b42435f3 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -144,7 +144,6 @@ describe('SearchAutocomplete', function () { jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); simulateDesktop(); jest.useFakeTimers(); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 3c0401ceb7b..d4415598382 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -3668,7 +3668,6 @@ describe('ComboBox', function () { describe('mobile combobox', function () { beforeEach(() => { simulateMobile(); - }); afterEach(() => { diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index be16a4b0036..da7c4ad6960 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -93,9 +93,6 @@ describe('Picker', function () { expect(queryByRole('listbox')).toBeNull(); let picker = selectTester.trigger; - // let picker = getByRole('button'); - // await user.click(picker); - // act(() => jest.runAllTimers()); await selectTester.open(); let listbox = selectTester.listbox; From f7a1e2069892f8990466b7f647bd7d41e08e8dea Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 1 May 2025 17:12:59 -0700 Subject: [PATCH 69/90] fix picker tests --- .../@react-aria/virtualizer/src/ScrollView.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 716baaa5022..0c615ff1a2d 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -198,8 +198,8 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); - let [, setUpdate] = useState({}); - let queuedUpdateSize = useRef(false); + let [update, setUpdate] = useState({}); + useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -212,23 +212,21 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject updateSize(flushSync)); } } - if (queuedUpdateSize.current) { - queuedUpdateSize.current = false; - updateSize(fn => fn()); - } - lastContentSize.current = contentSize; }); + useLayoutEffect(() => { + updateSize(fn => fn()); + }, [update]) + let onResize = useCallback(() => { updateSize(flushSync); }, [updateSize]); From 53f9158bbdd3b61314d832a964bafa824b52d6d5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 1 May 2025 17:22:41 -0700 Subject: [PATCH 70/90] fix collection index incrementing when performing insertBefore previously was only checking elements in the collection after the loading spinner node, thus items loaded async into the collection all remained with incorrect index, resulting in incorrect aria-rowIndex --- packages/@react-aria/collections/src/Document.ts | 9 ++++----- .../@react-aria/virtualizer/src/ScrollView.tsx | 6 +++--- packages/react-aria-components/test/Table.test.js | 15 +++++---------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 1385e18a413..f462be4fe74 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -155,7 +155,6 @@ export class BaseNode { newNode.nextSibling = referenceNode; newNode.previousSibling = referenceNode.previousSibling; newNode.index = referenceNode.index; - if (this.firstChild === referenceNode) { this.firstChild = newNode; } else if (referenceNode.previousSibling) { @@ -165,7 +164,7 @@ export class BaseNode { referenceNode.previousSibling = newNode; newNode.parentNode = referenceNode.parentNode; - this.invalidateChildIndices(referenceNode); + this.invalidateChildIndices(newNode); this.ownerDocument.queueUpdate(); } @@ -173,7 +172,7 @@ export class BaseNode { if (child.parentNode !== this || !this.ownerDocument.isMounted) { return; } - + if (child.nextSibling) { this.invalidateChildIndices(child.nextSibling); child.nextSibling.previousSibling = child.previousSibling; @@ -279,7 +278,7 @@ export class ElementNode extends BaseNode { this.node = this.node.clone(); this.isMutated = true; } - + this.ownerDocument.markDirty(this); return this.node; } @@ -497,7 +496,7 @@ export class Document = BaseCollection> extend if (this.dirtyNodes.size === 0 || this.queuedRender) { return; } - + // Only trigger subscriptions once during an update, when the first item changes. // React's useSyncExternalStore will call getCollection immediately, to check whether the snapshot changed. // If so, React will queue a render to happen after the current commit to our fake DOM finishes. diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 0c615ff1a2d..0d84e4bc73c 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -199,7 +199,6 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); let [update, setUpdate] = useState({}); - useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -210,8 +209,8 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateSize(fn => fn()); }, [update]) diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index db001932bda..ab814a37bac 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -2031,15 +2031,6 @@ describe('Table', () => { beforeAll(() => { clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); - // jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 150); - // jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); - // jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementationOnce(() => 0).mockImplementation(function () { - // if (this.getAttribute('role') === 'grid') { - // return 50; - // } - - // return 25; - // }); }); beforeEach(() => { @@ -2047,7 +2038,6 @@ describe('Table', () => { }); afterAll(function () { - // TODO fix these clientWidth.mockReset(); clientHeight.mockReset(); }); @@ -2137,6 +2127,11 @@ describe('Table', () => { expect(sentinelParentStyles.top).toBe('0px'); expect(sentinelParentStyles.height).toBe('30px'); }); + + it('should have the correct row indicies after loading more items', async () => { + // TODO: first render without items and is loading + // then render with items and double check + }); }); }); From 4755b9168e5d5d8e4f1baf3e16bbfa38c2d9b90e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 5 May 2025 10:31:16 -0700 Subject: [PATCH 71/90] fix tests and lint --- packages/@react-aria/virtualizer/src/ScrollView.tsx | 4 ++-- packages/react-aria-components/test/GridList.test.js | 3 +-- packages/react-aria-components/test/ListBox.test.js | 3 +-- packages/react-aria-components/test/Table.test.js | 3 +-- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 0d84e4bc73c..0b94ba31c09 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -213,7 +213,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject updateSize(flushSync)); } @@ -225,7 +225,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateSize(fn => fn()); - }, [update]) + }, [update]); let onResize = useCallback(() => { updateSize(flushSync); diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index a093a040e5b..53dec874c5a 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -946,15 +946,14 @@ describe('GridList', () => { let tree = render(); let sentinel = tree.getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenCalledTimes(2); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(0); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); + observe.mockClear(); tree.rerender(); - expect(observe).toHaveBeenCalledTimes(3); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(1); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index a8271eec50b..1a4bffa8a42 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1417,15 +1417,14 @@ describe('ListBox', () => { let tree = render(); let sentinel = tree.getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenCalledTimes(2); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(0); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); + observe.mockClear(); tree.rerender(); - expect(observe).toHaveBeenCalledTimes(3); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(1); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index ab814a37bac..86d4e6d800f 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1931,15 +1931,14 @@ describe('Table', () => { let tree = render(); let sentinel = tree.getByTestId('loadMoreSentinel'); - expect(observe).toHaveBeenCalledTimes(2); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(0); + observe.mockClear(); act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); tree.rerender(); - expect(observe).toHaveBeenCalledTimes(3); expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(1); From e2b8c00238da91d177d4ce1ec5e395d9f6001673 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 5 May 2025 11:33:07 -0700 Subject: [PATCH 72/90] update test-util dev dep in S2 so 16/17 tests pass --- packages/@react-spectrum/s2/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 4e0adcb8e9b..e2c9d8917e5 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -122,7 +122,7 @@ "devDependencies": { "@adobe/spectrum-tokens": "^13.0.0-beta.56", "@parcel/macros": "^2.14.0", - "@react-aria/test-utils": "1.0.0-alpha.3", + "@react-aria/test-utils": "^1.0.0-alpha.6", "@storybook/jest": "^0.2.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", From a0b9e5dd2f1c9bcc5d1cb555977dbf1e0eafd655 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 5 May 2025 11:34:36 -0700 Subject: [PATCH 73/90] forgot yarn lock change --- yarn.lock | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/yarn.lock b/yarn.lock index 96be38a6a2e..acc8b7087b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6726,21 +6726,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/test-utils@npm:1.0.0-alpha.3": - version: 1.0.0-alpha.3 - resolution: "@react-aria/test-utils@npm:1.0.0-alpha.3" - dependencies: - "@swc/helpers": "npm:^0.5.0" - peerDependencies: - "@testing-library/react": ^15.0.7 - "@testing-library/user-event": ^13.0.0 || ^14.0.0 - jest: ^29.5.0 - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/f750e02be86a37f4b26f7ec055be6ca105c11e4fcb24ec52ddd8d352395a532d7f466789de7bed9a44c58f23c5c518387181eda80c4979ee684d0ae358d4d090 - languageName: node - linkType: hard - -"@react-aria/test-utils@npm:1.0.0-alpha.6, @react-aria/test-utils@workspace:packages/@react-aria/test-utils": +"@react-aria/test-utils@npm:1.0.0-alpha.6, @react-aria/test-utils@npm:^1.0.0-alpha.6, @react-aria/test-utils@workspace:packages/@react-aria/test-utils": version: 0.0.0-use.local resolution: "@react-aria/test-utils@workspace:packages/@react-aria/test-utils" dependencies: @@ -7961,7 +7947,7 @@ __metadata: "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" "@react-aria/separator": "npm:^3.4.8" - "@react-aria/test-utils": "npm:1.0.0-alpha.3" + "@react-aria/test-utils": "npm:^1.0.0-alpha.6" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" "@react-stately/layout": "npm:^4.2.2" From cb52f58d735c7d3bb0dd2a335f66888761c0052f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 5 May 2025 14:33:55 -0700 Subject: [PATCH 74/90] fix overflow on windows potentially... --- packages/@react-spectrum/s2/src/ComboBox.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index bbb69b968ba..beb756b9724 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -153,7 +153,9 @@ export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ width: 'full', boxSizing: 'border-box', maxHeight: '[inherit]', - overflow: 'auto', + // TODO: Might help with horizontal scrolling happening on Windows, will need to check somehow. Otherwise, revert back to overflow: auto + overflowY: 'auto', + overflowX: 'hidden', fontFamily: 'sans', fontSize: 'control' }); From be7d23369904c813ce7f9ed5b1a2c9dff9bd92a6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 5 May 2025 16:35:36 -0700 Subject: [PATCH 75/90] fix rowindex calculation when filtering async s2 combobox the document didnt seem to be both updating its _minInvalidChildIndex nor updating its child indicies properly when the combobox list was filtered async (aka the collection got new items added and removed via insertBefore and/or removeChild. Additionally, we didnt see to ever call updateChildIndices on the Document other than the first time the collection loaded --- .../@react-aria/collections/src/Document.ts | 14 +- .../@react-aria/interactions/src/usePress.ts | 4 +- .../s2/stories/Picker.stories.tsx | 1 + .../@react-spectrum/s2/test/Combobox.test.tsx | 42 +++++- .../@react-spectrum/s2/test/Picker.test.tsx | 120 ++++++++++++++++++ .../test/ListBox.test.js | 9 +- 6 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 packages/@react-spectrum/s2/test/Picker.test.tsx diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index f462be4fe74..eb827e335ef 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -103,7 +103,7 @@ export class BaseNode { } private invalidateChildIndices(child: ElementNode): void { - if (this._minInvalidChildIndex == null || child.index < this._minInvalidChildIndex.index) { + if (this._minInvalidChildIndex == null || !this._minInvalidChildIndex.isConnected || child.index < this._minInvalidChildIndex.index) { this._minInvalidChildIndex = child; } } @@ -154,7 +154,11 @@ export class BaseNode { newNode.nextSibling = referenceNode; newNode.previousSibling = referenceNode.previousSibling; - newNode.index = referenceNode.index; + // Ensure that the newNode's index is less than that of the reference node so that + // invalidateChildIndices will properly use the newNode as the _minInvalidChildIndex, thus making sure + // we will properly update the indexes of all sibiling nodes after the newNode. The value here doesn't matter + // since updateChildIndices should calculate the proper indexes. + newNode.index = referenceNode.index - 1; if (this.firstChild === referenceNode) { this.firstChild = newNode; } else if (referenceNode.previousSibling) { @@ -469,6 +473,12 @@ export class Document = BaseCollection> extend } // Next, update dirty collection nodes. + // TODO: when updateCollection is called here, shouldn't we be calling this.updateChildIndicies as well? Theoretically it should only update + // nodes from _minInvalidChildIndex onwards so the increase in dirtyNodes should be minimal. + // Is element.updateNode supposed to handle that (it currently assumes the index stored on the node is correct already). + // At the moment, without this call to updateChildIndicies, filtering an async combobox doesn't actually update the index values of the + // updated collection... + this.updateChildIndices(); for (let element of this.dirtyNodes) { if (element instanceof ElementNode) { if (element.isConnected && !element.isHidden) { diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index e48240a10c3..474fc25ad04 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -372,7 +372,7 @@ export function usePress(props: PressHookProps): PressResult { if (isDisabled) { e.preventDefault(); } - + // If triggered from a screen reader or by using element.click(), // trigger as if it were a keyboard click. if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { @@ -820,7 +820,7 @@ export function usePress(props: PressHookProps): PressResult { // Only apply touch-action if not already set by another CSS rule. let style = getOwnerWindow(element).getComputedStyle(element); if (style.touchAction === 'auto') { - // touchAction: 'manipulation' is supposed to be equivalent, but in + // touchAction: 'manipulation' is supposed to be equivalent, but in // Safari it causes onPointerCancel not to fire on scroll. // https://bugs.webkit.org/show_bug.cgi?id=240917 (element as HTMLElement).style.touchAction = 'pan-x pan-y pinch-zoom'; diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index ac9e49dc184..852f6c5b924 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -261,6 +261,7 @@ export const AsyncPickerStory = { render: AsyncPicker, args: { ...Example.args, + label: 'Star Wars Character', delay: 50 }, name: 'Async loading picker', diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index 7047d0f93df..11db4117c10 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -21,6 +21,19 @@ import userEvent from '@testing-library/user-event'; describe('Combobox', () => { let user; let testUtilUser = new User(); + function DynamicCombobox(props) { + let {items, loadingState, onLoadMore, ...otherProps} = props; + return ( + + {(item: any) => {item.name}} + + ); + } beforeAll(function () { jest.useFakeTimers(); @@ -101,7 +114,7 @@ describe('Combobox', () => { expect(onLoadMore).toHaveBeenCalledTimes(2); }); - it('should omit the laoder from the count of items', async () => { + it('should omit the loader from the count of items', async () => { jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'MacIntel'); let tree = render( @@ -122,4 +135,31 @@ describe('Combobox', () => { await user.keyboard('C'); expect(announce).toHaveBeenLastCalledWith('2 options available.'); }); + + it('should properly calculate the expected row index values even when the content changes', async () => { + let items = [{name: 'Chocolate'}, {name: 'Mint'}, {name: 'Chocolate Chip'}]; + let tree = render(); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container, interactionType: 'mouse'}); + await comboboxTester.open(); + let options = comboboxTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + + tree.rerender(); + options = comboboxTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + + // A bit contrived, but essentially testing a combinaiton of insertions/deletions along side some of the old entries remaining + let newItems = [{name: 'Chocolate Mint'}, {name: 'Chocolate'}, {name: 'Chocolate Chip'}, {name: 'Chocolate Chip Cookie Dough'}] + tree.rerender(); + + options = comboboxTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + }); }); diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx new file mode 100644 index 00000000000..4f2f4c24fa4 --- /dev/null +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2025 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 {act, render, setupIntersectionObserverMock} from '@react-spectrum/test-utils-internal'; +import {Picker, PickerItem} from '../src'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; + +describe('Picker', () => { + let testUtilUser = new User(); + function DynamicPicker(props) { + let {items, isLoading, onLoadMore, ...otherProps} = props; + return ( + + {(item: any) => {item.name}} + + ); + } + + beforeAll(function () { + jest.useFakeTimers(); + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it.skip('should only call loadMore whenever intersection is detected', async () => { + let onLoadMore = jest.fn(); + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); + + let tree = render( + + Chocolate + Mint + Strawberry + Vanilla + Chocolate Chip Cookie Dough + + ); + + let selectTester = testUtilUser.createTester('Select', {root: tree.container}); + expect(selectTester.listbox).toBeFalsy(); + selectTester.setInteractionType('mouse'); + await selectTester.open(); + + expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + act(() => {jest.runAllTimers();}); + + tree.rerender( + + Chocolate + Mint + Strawberry + Vanilla + Chocolate Chip Cookie Dough + + ); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + act(() => {jest.runAllTimers();}); + // Note that if this was using useAsyncList, we'd be shielded from extranous onLoadMore calls but + // we want to leave that to user discretion + expect(onLoadMore).toHaveBeenCalledTimes(2); + }); + + it.skip('should properly calculate the expected row index values', async () => { + let items = [{name: 'Chocolate'}, {name: 'Mint'}, {name: 'Chocolate Chip'}]; + let tree = render(); + + let selectTester = testUtilUser.createTester('Select', {root: tree.container}); + await selectTester.open(); + let options = selectTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + + tree.rerender(); + options = selectTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + + let newItems = [...items, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}] + tree.rerender(); + + options = selectTester.options(); + for (let [index, option] of options.entries()) { + expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); + } + }); +}); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 1a4bffa8a42..7ca4f076d35 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1365,6 +1365,7 @@ describe('ListBox', () => { let onLoadMore = jest.fn(); let observe = jest.fn(); afterEach(() => { + jest.runAllTimers(); jest.clearAllMocks(); }); @@ -1444,16 +1445,16 @@ describe('ListBox', () => { clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); }); - beforeEach(() => { + afterEach(() => { act(() => {jest.runAllTimers();}); }); - afterAll(function () { + afterAll(() => { clientWidth.mockReset(); clientHeight.mockReset(); }); - let VirtualizedAsyncListbox = (props) => { + function VirtualizedAsyncListbox(props) { let {items, isLoading, onLoadMore, ...listBoxProps} = props; return ( { }); // TODO: for some reason this tree renders empty if ran with the above test... + // Even if the above test doesn't do anything within it, the below tree won't render with content until the above test + // is fully commented out (aka even the it(...)) // It thinks that the contextSize is 0 and never updates it.skip('should not reserve room for the loader if isLoading is false', () => { let tree = render(); From 69b7db17a8e5d8af3308bd870808c50be86f8e2a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 6 May 2025 09:43:22 -0700 Subject: [PATCH 76/90] make S2 picker button not throw warning when rending in fake DOM --- packages/@react-spectrum/s2/src/Picker.tsx | 6 ++++-- packages/@react-spectrum/s2/test/Combobox.test.tsx | 2 +- packages/@react-spectrum/s2/test/Picker.test.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index f1c7f1d385d..be3bba3b4cc 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -44,6 +44,7 @@ import { } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; +import {createHideableComponent} from '@react-aria/collections'; import { Divider, listbox, @@ -463,7 +464,8 @@ interface PickerButtonInnerProps extends PickerStyleProps, Omi buttonRef: RefObject } -function PickerButton(props: PickerButtonInnerProps) { +// Needs to be hidable component or otherwise the PressResponder throws a warning when rendered in the fake DOM and tries to register +const PickerButton = createHideableComponent(function PickerButton(props: PickerButtonInnerProps) { let { isOpen, isQuiet, @@ -555,7 +557,7 @@ function PickerButton(props: PickerButtonInnerProps) { ); -} +}); export interface PickerItemProps extends Omit, StyleProps { children: ReactNode diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index 11db4117c10..f59a298dc93 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -154,7 +154,7 @@ describe('Combobox', () => { } // A bit contrived, but essentially testing a combinaiton of insertions/deletions along side some of the old entries remaining - let newItems = [{name: 'Chocolate Mint'}, {name: 'Chocolate'}, {name: 'Chocolate Chip'}, {name: 'Chocolate Chip Cookie Dough'}] + let newItems = [{name: 'Chocolate Mint'}, {name: 'Chocolate'}, {name: 'Chocolate Chip'}, {name: 'Chocolate Chip Cookie Dough'}]; tree.rerender(); options = comboboxTester.options(); diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index 4f2f4c24fa4..d29765b3575 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -25,7 +25,7 @@ describe('Picker', () => { label="Test picker" items={items} isLoading={isLoading} - onLoadMore={onLoadMore}> + onLoadMore={onLoadMore}> {(item: any) => {item.name}} ); @@ -46,7 +46,7 @@ describe('Picker', () => { jest.restoreAllMocks(); }); - it.skip('should only call loadMore whenever intersection is detected', async () => { + it('should only call loadMore whenever intersection is detected', async () => { let onLoadMore = jest.fn(); let observe = jest.fn(); let observer = setupIntersectionObserverMock({ @@ -92,7 +92,7 @@ describe('Picker', () => { expect(onLoadMore).toHaveBeenCalledTimes(2); }); - it.skip('should properly calculate the expected row index values', async () => { + it('should properly calculate the expected row index values', async () => { let items = [{name: 'Chocolate'}, {name: 'Mint'}, {name: 'Chocolate Chip'}]; let tree = render(); @@ -109,7 +109,7 @@ describe('Picker', () => { expect(option).toHaveAttribute('aria-posinset', `${index + 1}`); } - let newItems = [...items, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}] + let newItems = [...items, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}]; tree.rerender(); options = selectTester.options(); From 3cbb286f7904bb8a08b7c9daa03a4f7ea6ca6ad2 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 6 May 2025 10:19:35 -0700 Subject: [PATCH 77/90] fix s2 picker scroll selected item into view --- packages/@react-spectrum/s2/src/Picker.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 3dbead9b0eb..0fef84ad161 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -278,6 +278,8 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick menuOffset = 8; } + let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8}) + return ( + layout={layout}> From 923e260c5c0129a1b45a428c3bbbf0ed473de270 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 6 May 2025 10:23:41 -0700 Subject: [PATCH 78/90] fix lint --- packages/@react-spectrum/s2/src/Picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 0fef84ad161..2c77daf3d03 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -278,7 +278,7 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick menuOffset = 8; } - let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8}) + let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8}); return ( Date: Tue, 6 May 2025 11:03:23 -0700 Subject: [PATCH 79/90] clean up and add row index tests for GridList and Table --- .../picker/test/Picker.test.js | 3 - .../s2/chromatic/Combobox.stories.tsx | 14 ++++- .../s2/chromatic/Picker.stories.tsx | 9 ++- packages/@react-spectrum/s2/package.json | 1 - .../react-aria-components/src/GridList.tsx | 4 +- .../react-aria-components/src/ListBox.tsx | 4 +- packages/react-aria-components/src/Table.tsx | 1 + .../test/GridList.test.js | 62 ++++++++++++++----- .../react-aria-components/test/Table.test.js | 57 ++++++++++++----- yarn.lock | 1 - 10 files changed, 112 insertions(+), 44 deletions(-) diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 81a1c47a9a0..36421aaf844 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1025,9 +1025,6 @@ describe('Picker', function () { expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Empty'); - // TODO: this test (along with others in this suite) fails because we seem to be detecting that the button is being focused after the - // dropdown is opened, resulting in the dropdown closing due to useOverlay interactOutside logic - // Seems to specifically happen if the Picker has a selected item and the user tries to open the Picker await selectTester.selectOption({option: 'Zero'}); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenLastCalledWith('0'); diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index f9ae13d20c8..59d36c0fdf4 100644 --- a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx @@ -87,7 +87,8 @@ export const WithEmptyState = { export const WithInitialLoading = { ...EmptyCombobox, args: { - loadingState: 'loading' + loadingState: 'loading', + label: 'Initial loading' }, play: async ({canvasElement}) => { await userEvent.tab(); @@ -101,7 +102,8 @@ export const WithInitialLoading = { export const WithLoadMore = { ...Example, args: { - loadingState: 'loadingMore' + loadingState: 'loadingMore', + label: 'Loading more' }, play: async ({canvasElement}) => { await userEvent.tab(); @@ -114,6 +116,10 @@ export const WithLoadMore = { export const AsyncResults = { ...AsyncComboBoxStory, + args: { + ...AsyncComboBoxStory.args, + delay: 2000 + }, play: async ({canvasElement}) => { await userEvent.tab(); await userEvent.keyboard('{ArrowDown}'); @@ -127,6 +133,10 @@ export const AsyncResults = { export const Filtering = { ...AsyncComboBoxStory, + args: { + ...AsyncComboBoxStory.args, + delay: 2000 + }, play: async ({canvasElement}) => { await userEvent.tab(); await userEvent.keyboard('{ArrowDown}'); diff --git a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx index 5960a87cb58..43d62aa828d 100644 --- a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx @@ -19,7 +19,8 @@ import {userEvent, waitFor, within} from '@storybook/testing-library'; const meta: Meta> = { component: Picker, parameters: { - chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true} + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true}, + chromatic: {ignoreSelectors: ['[role="progressbar"]']} }, tags: ['autodocs'], title: 'S2 Chromatic/Picker' @@ -71,7 +72,7 @@ export const ContextualHelp = { export const EmptyAndLoading = { render: () => ( - + {[]} ), @@ -88,6 +89,10 @@ export const EmptyAndLoading = { export const AsyncResults = { ...AsyncPickerStory, + args: { + ...AsyncPickerStory.args, + delay: 2000 + }, play: async ({canvasElement}) => { let body = canvasElement.ownerDocument.body; await waitFor(() => { diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index e2c9d8917e5..b2e58a2ecea 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -137,7 +137,6 @@ "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", "@react-aria/overlays": "^3.27.0", - "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 9c87edfaa4b..2a3856326da 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -217,7 +217,7 @@ function GridListInner({props, collection, gridListRef: ref}: if (isEmpty && props.renderEmptyState) { let content = props.renderEmptyState(renderValues); emptyState = ( -
+
{content}
@@ -532,7 +532,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu return ( <> - {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* TODO: Alway render the sentinel. Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */}
diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 919c748e189..dcf58e19a9e 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -230,8 +230,6 @@ function ListBoxInner({state: inputState, props, listBoxRef}: ); } - // TODO: Think about if completely empty state. Do we leave it up to the user to setup the two states for empty and empty + loading? - // Do we add a data attibute/prop/renderprop to ListBox for isLoading return (
- {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* TODO: Alway render the sentinel. Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */}
diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index f111c69d0f1..e6828bb904d 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1398,6 +1398,7 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct return ( <> {/* TODO weird structure? Renders a extra row but we hide via inert so maybe ok? */} + {/* TODO: Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */} diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 53dec874c5a..61021742559 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -867,9 +867,9 @@ describe('GridList', () => { {name: 'Bar'}, {name: 'Baz'} ]; - let renderEmptyState = () => { + let renderEmptyState = (loadingState) => { return ( -
empty state
+ loadingState === 'loading' ?
loading
:
empty state
); }; let AsyncGridList = (props) => { @@ -979,7 +979,7 @@ describe('GridList', () => { }); let VirtualizedAsyncGridList = (props) => { - let {items, isLoading, onLoadMore, ...listBoxProps} = props; + let {items, loadingState, onLoadMore, ...listBoxProps} = props; return ( { renderEmptyState()}> + renderEmptyState={() => renderEmptyState(loadingState)}> {(item) => ( {item.name} )} - + Loading... @@ -1005,7 +1005,7 @@ describe('GridList', () => { }; it('should always render the sentinel even when virtualized', async () => { - let tree = render(); + let tree = render(); let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); let rows = gridListTester.rows; @@ -1049,20 +1049,52 @@ describe('GridList', () => { expect(sentinelParentStyles.top).toBe('0px'); expect(sentinelParentStyles.height).toBe('0px'); - // Setting isLoading will render the loader even if the list is empty. - tree.rerender(); + tree.rerender(); rows = gridListTester.rows; - expect(rows).toHaveLength(2); - emptyStateRow = rows[1]; - expect(emptyStateRow).toHaveTextContent('empty state'); - - let loadingRow = rows[0]; - expect(loadingRow).toHaveTextContent('Loading...'); + expect(rows).toHaveLength(1); + emptyStateRow = rows[0]; + expect(emptyStateRow).toHaveTextContent('loading'); sentinel = within(gridListTester.gridlist).getByTestId('loadMoreSentinel'); sentinelParentStyles = sentinel.parentElement.parentElement.style; expect(sentinelParentStyles.top).toBe('0px'); - expect(sentinelParentStyles.height).toBe('30px'); + expect(sentinelParentStyles.height).toBe('0px'); + }); + + it('should have the correct row indicies after loading more items', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(1); + + let loaderRow = rows[0]; + expect(loaderRow).toHaveAttribute('aria-rowindex', '1'); + expect(loaderRow).toHaveTextContent('loading'); + for (let [index, row] of rows.entries()) { + expect(row).toHaveAttribute('aria-rowindex', `${index + 1}`); + } + + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(7); + expect(within(gridListTester.gridlist).queryByText('loading')).toBeFalsy(); + for (let [index, row] of rows.entries()) { + expect(row).toHaveAttribute('aria-rowindex', `${index + 1}`); + } + + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(8); + loaderRow = rows[7]; + expect(loaderRow).toHaveAttribute('aria-rowindex', '51'); + for (let [index, row] of rows.entries()) { + if (index === 7) { + continue; + } else { + expect(row).toHaveAttribute('aria-rowindex', `${index + 1}`); + } + } }); }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 86d4e6d800f..0d8cf70d593 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -2042,7 +2042,7 @@ describe('Table', () => { }); let VirtualizedTableLoad = (props) => { - let {items, isLoading, onLoadMore} = props; + let {items, loadingState, onLoadMore} = props; return ( @@ -2051,7 +2051,7 @@ describe('Table', () => { Foo Bar - 'No results'}> + loadingState === 'loading' ? 'loading' : 'No results'}> {(item) => ( @@ -2060,7 +2060,7 @@ describe('Table', () => { )} - +
spinner
@@ -2070,7 +2070,7 @@ describe('Table', () => { }; it('should always render the sentinel even when virtualized', () => { - let tree = render(); + let tree = render(); let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); let rows = tableTester.rows; expect(rows).toHaveLength(7); @@ -2111,25 +2111,52 @@ describe('Table', () => { expect(sentinelParentStyles.top).toBe('0px'); expect(sentinelParentStyles.height).toBe('0px'); - // Setting isLoading will render the loader even if the table is empty. - tree.rerender(); + tree.rerender(); rows = tableTester.rows; - expect(rows).toHaveLength(2); - emptyStateRow = rows[1]; - expect(emptyStateRow).toHaveTextContent('No results'); - - let loadingRow = rows[0]; - expect(loadingRow).toHaveTextContent('spinner'); + expect(rows).toHaveLength(1); + emptyStateRow = rows[0]; + expect(emptyStateRow).toHaveTextContent('loading'); sentinel = within(tableTester.table).getByTestId('loadMoreSentinel', {hidden: true}); sentinelParentStyles = sentinel.parentElement.parentElement.style; expect(sentinelParentStyles.top).toBe('0px'); - expect(sentinelParentStyles.height).toBe('30px'); + expect(sentinelParentStyles.height).toBe('0px'); }); it('should have the correct row indicies after loading more items', async () => { - // TODO: first render without items and is loading - // then render with items and double check + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + let rows = tableTester.rows; + expect(rows).toHaveLength(1); + + let loaderRow = rows[0]; + expect(loaderRow).toHaveAttribute('aria-rowindex', '2'); + expect(loaderRow).toHaveTextContent('loading'); + for (let [index, row] of rows.entries()) { + // the header row is the first row but isn't included in "rows" so add +2 + expect(row).toHaveAttribute('aria-rowindex', `${index + 2}`); + } + + tree.rerender(); + rows = tableTester.rows; + expect(rows).toHaveLength(6); + expect(within(tableTester.table).queryByText('spinner')).toBeFalsy(); + for (let [index, row] of rows.entries()) { + expect(row).toHaveAttribute('aria-rowindex', `${index + 2}`); + } + + tree.rerender(); + rows = tableTester.rows; + expect(rows).toHaveLength(7); + loaderRow = rows[6]; + expect(loaderRow).toHaveAttribute('aria-rowindex', '52'); + for (let [index, row] of rows.entries()) { + if (index === 6) { + continue; + } else { + expect(row).toHaveAttribute('aria-rowindex', `${index + 2}`); + } + } }); }); }); diff --git a/yarn.lock b/yarn.lock index acc8b7087b2..da291e41e62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7946,7 +7946,6 @@ __metadata: "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" - "@react-aria/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:^1.0.0-alpha.6" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" From 65c2aca4f5fbfeedcd150c24f9e5be6143bc05cc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 6 May 2025 11:21:53 -0700 Subject: [PATCH 80/90] small improvements from testing session --- packages/@react-spectrum/s2/stories/ComboBox.stories.tsx | 1 + packages/react-aria-components/example/index.css | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index c1b32d913a3..abebee6cfde 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -237,6 +237,7 @@ const AsyncComboBox = (args: any) => { return ( Date: Tue, 6 May 2025 14:59:15 -0700 Subject: [PATCH 81/90] update yarn lock --- packages/@react-spectrum/s2/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 74c0a29fcda..c631e2beaf8 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -136,7 +136,6 @@ "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", "@react-aria/overlays": "^3.27.0", - "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/yarn.lock b/yarn.lock index 62f07046e46..37f6db6fb06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7944,7 +7944,6 @@ __metadata: "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" - "@react-aria/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" From 3ae8f29471c1fa38cc01dc6eb7b156dfcbc327a8 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 6 May 2025 15:18:36 -0700 Subject: [PATCH 82/90] remove comment --- packages/@react-spectrum/s2/src/ComboBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index beb756b9724..a0379816387 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -170,7 +170,7 @@ export let listboxItem = style({ value: centerPadding() }, paddingBottom: '--labelPadding', - backgroundColor: { // TODO: revisit color when I have access to dev mode again + backgroundColor: { default: 'transparent', isFocused: baseColor('gray-100').isFocusVisible }, From 16808857ef004d3734e2341ce09ca2f4be04a4de Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 8 May 2025 10:30:33 -0700 Subject: [PATCH 83/90] review comments --- .../@react-aria/interactions/src/usePress.ts | 4 +-- packages/@react-spectrum/s2/package.json | 5 ++-- packages/@react-stately/select/package.json | 4 +-- .../select/src/useSelectState.ts | 29 +++++++++---------- .../react-aria-components/src/GridList.tsx | 6 ++-- .../react-aria-components/src/ListBox.tsx | 6 ++-- packages/react-aria-components/src/Table.tsx | 6 ++-- packages/react-stately/package.json | 3 +- yarn.lock | 8 ++--- 9 files changed, 30 insertions(+), 41 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 474fc25ad04..e48240a10c3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -372,7 +372,7 @@ export function usePress(props: PressHookProps): PressResult { if (isDisabled) { e.preventDefault(); } - + // If triggered from a screen reader or by using element.click(), // trigger as if it were a keyboard click. if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { @@ -820,7 +820,7 @@ export function usePress(props: PressHookProps): PressResult { // Only apply touch-action if not already set by another CSS rule. let style = getOwnerWindow(element).getComputedStyle(element); if (style.touchAction === 'auto') { - // touchAction: 'manipulation' is supposed to be equivalent, but in + // touchAction: 'manipulation' is supposed to be equivalent, but in // Safari it causes onPointerCancel not to fire on scroll. // https://bugs.webkit.org/show_bug.cgi?id=240917 (element as HTMLElement).style.touchAction = 'pan-x pan-y pinch-zoom'; diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index b2e58a2ecea..886fda73132 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -122,7 +122,7 @@ "devDependencies": { "@adobe/spectrum-tokens": "^13.0.0-beta.56", "@parcel/macros": "^2.14.0", - "@react-aria/test-utils": "^1.0.0-alpha.6", + "@react-aria/test-utils": "1.0.0-alpha.6", "@storybook/jest": "^0.2.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", @@ -154,8 +154,7 @@ "react-stately": "^3.37.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^18.0.0 || ^19.0.0-rc.1" + "react": "^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index 2e46b1c0954..e5252c81822 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -22,7 +22,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/utils": "^3.28.2", "@react-stately/form": "^3.1.3", "@react-stately/list": "^3.12.1", "@react-stately/overlays": "^3.6.15", @@ -31,8 +30,7 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index 771104b7799..62cf0bfb03b 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -15,7 +15,6 @@ import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {SelectProps} from '@react-types/select'; import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; -import {useEffectEvent} from '@react-aria/utils'; import {useMemo, useState} from 'react'; export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} @@ -64,27 +63,25 @@ export function useSelectState(props: SelectStateOptions): let [isFocused, setFocused] = useState(false); let isEmpty = useMemo(() => listState.collection.size === 0 || (listState.collection.size === 1 && listState.collection.getItem(listState.collection.getFirstKey()!)?.type === 'loader'), [listState.collection]); - let open = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { - if (!isEmpty) { - setFocusStrategy(focusStrategy); - triggerState.open(); - } - }); - - let toggle = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { - if (!isEmpty) { - setFocusStrategy(focusStrategy); - triggerState.toggle(); - } - }); return { ...validationState, ...listState, ...triggerState, focusStrategy, - open, - toggle, + open(focusStrategy: FocusStrategy | null = null) { + // Don't open if the collection is empty. + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.open(); + } + }, + toggle(focusStrategy: FocusStrategy | null = null) { + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.toggle(); + } + }, isFocused, setFocused }; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 2a3856326da..93c8a23d136 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -539,11 +539,11 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu
{isLoading && renderProps.children && (
+ ref={ref}>
diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index dcf58e19a9e..5002c4b5b2d 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -516,12 +516,12 @@ export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', fun
{isLoading && renderProps.children && (
+ ref={ref}> {renderProps.children}
)} diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e6828bb904d..aca00d6eef2 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1405,10 +1405,10 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct {isLoading && renderProps.children && ( + {...renderProps} + role="row" + ref={ref}> {renderProps.children} diff --git a/packages/react-stately/package.json b/packages/react-stately/package.json index dd6bc73829c..7caf830b77b 100644 --- a/packages/react-stately/package.json +++ b/packages/react-stately/package.json @@ -52,8 +52,7 @@ "@react-types/shared": "^3.29.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "devDependencies": { "@babel/cli": "^7.24.1", diff --git a/yarn.lock b/yarn.lock index 750d8871c01..53dcadb8fe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6742,7 +6742,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/test-utils@npm:1.0.0-alpha.6, @react-aria/test-utils@npm:^1.0.0-alpha.6, @react-aria/test-utils@workspace:packages/@react-aria/test-utils": +"@react-aria/test-utils@npm:1.0.0-alpha.6, @react-aria/test-utils@workspace:packages/@react-aria/test-utils": version: 0.0.0-use.local resolution: "@react-aria/test-utils@workspace:packages/@react-aria/test-utils" dependencies: @@ -7962,7 +7962,7 @@ __metadata: "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" - "@react-aria/test-utils": "npm:^1.0.0-alpha.6" + "@react-aria/test-utils": "npm:1.0.0-alpha.6" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" "@react-stately/layout": "npm:^4.2.2" @@ -7985,7 +7985,6 @@ __metadata: react-stately: "npm:^3.37.0" peerDependencies: react: ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -8763,7 +8762,6 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/select@workspace:packages/@react-stately/select" dependencies: - "@react-aria/utils": "npm:^3.28.2" "@react-stately/form": "npm:^3.1.3" "@react-stately/list": "npm:^3.12.1" "@react-stately/overlays": "npm:^3.6.15" @@ -8772,7 +8770,6 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -27895,7 +27892,6 @@ __metadata: "@react-types/shared": "npm:^3.29.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft From f4e5f0de569d5475bfff94771cfb73d268fb4a7a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 8 May 2025 10:39:35 -0700 Subject: [PATCH 84/90] missed yarn.lock conflict --- yarn.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index e93ae033211..53dcadb8fe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7962,11 +7962,7 @@ __metadata: "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" -<<<<<<< HEAD "@react-aria/test-utils": "npm:1.0.0-alpha.6" -======= - "@react-aria/test-utils": "npm:1.0.0-alpha.3" ->>>>>>> 3ae8f29471c1fa38cc01dc6eb7b156dfcbc327a8 "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" "@react-stately/layout": "npm:^4.2.2" From a69f298e5f52a3960488858ff467b2455509153f Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 8 May 2025 11:41:13 -0700 Subject: [PATCH 85/90] fix picker test --- packages/@react-spectrum/s2/test/Picker.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index d29765b3575..db41b9a5038 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -35,6 +35,7 @@ describe('Picker', () => { jest.useFakeTimers(); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); }); afterEach(() => { From 67423b25a2d4fcac16c94986d69690e1dea8261a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 8 May 2025 17:05:18 -0700 Subject: [PATCH 86/90] mixed up which react-dom dep to remove derp --- packages/@react-spectrum/s2/package.json | 3 ++- packages/@react-stately/combobox/package.json | 3 +-- yarn.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 886fda73132..54b255eac76 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -154,7 +154,8 @@ "react-stately": "^3.37.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0-rc.1" + "react": "^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index b4d1452b71f..1cb029b1b47 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -33,8 +33,7 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/yarn.lock b/yarn.lock index 53dcadb8fe0..8f73bc6912c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7985,6 +7985,7 @@ __metadata: react-stately: "npm:^3.37.0" peerDependencies: react: ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -8573,7 +8574,6 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft From f35df51d1eb13bb427266e89184f18822c97b18a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 9 May 2025 13:30:39 -0700 Subject: [PATCH 87/90] fix Combobox test so it properly catches previous Document bug tested with the Document changes removed and verified that the test fails properly. Swore it failed when I first wrote it... --- packages/@react-spectrum/s2/test/Combobox.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/test/Combobox.test.tsx b/packages/@react-spectrum/s2/test/Combobox.test.tsx index f59a298dc93..75e8ac8a7ec 100644 --- a/packages/@react-spectrum/s2/test/Combobox.test.tsx +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -39,6 +39,7 @@ describe('Combobox', () => { jest.useFakeTimers(); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); user = userEvent.setup({delay: null, pointerMap}); }); @@ -154,7 +155,7 @@ describe('Combobox', () => { } // A bit contrived, but essentially testing a combinaiton of insertions/deletions along side some of the old entries remaining - let newItems = [{name: 'Chocolate Mint'}, {name: 'Chocolate'}, {name: 'Chocolate Chip'}, {name: 'Chocolate Chip Cookie Dough'}]; + let newItems = [{name: 'Chocolate'}, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}, {name: 'Chocolate Chip'}]; tree.rerender(); options = comboboxTester.options(); From b9e2ad76d61c9e57f8908132a4f16529ef039b06 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 9 May 2025 14:59:13 -0700 Subject: [PATCH 88/90] fix cases where extra separators appeared on combobox filter/if load sentinel is present --- .../collections/src/CollectionBuilder.tsx | 8 ++++---- packages/@react-spectrum/s2/src/ComboBox.tsx | 16 +++++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index fd2c8b50cc5..80ec484bc02 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -157,9 +157,9 @@ function useSSRCollectionNode(Type: string, props: object, re return {children}; } -export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef) => ReactElement): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent

(type: string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement): (props: P & React.RefAttributes) => ReactElement | null { +export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent

(type: string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { let Component = ({node}) => render(node.props, node.props.ref, node); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let focusableProps = useContext(FocusableContext); @@ -190,7 +190,7 @@ export function createLeafComponent

(type: s return Result; } -export function createBranchComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { +export function createBranchComponent(type: string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null, useChildren: (props: P) => ReactNode = useCollectionChildren): (props: P & React.RefAttributes) => ReactElement | null { let Component = ({node}) => render(node.props, node.props.ref, node); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { let children = useChildren(props); diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 4f126953c89..5bc65a2f54a 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -26,6 +26,7 @@ import { ListBoxItemProps, ListBoxProps, ListLayout, + ListStateContext, Provider, SectionProps, UNSTABLE_ListBoxLoadingSentinel, @@ -56,6 +57,7 @@ import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeRefs, useResizeObserver, useSlotId} from '@react-aria/utils'; +import {Node} from 'react-stately'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {pressScale} from './pressScale'; @@ -278,10 +280,7 @@ export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ }); const separatorWrapper = style({ - display: { - ':is(:last-child > *)': 'none', - default: 'flex' - }, + display: 'flex', marginX: { size: { S: `[${edgeToText(24)}]`, @@ -694,7 +693,14 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps) { +export const Divider = /*#__PURE__*/ createLeafComponent('separator', function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef, node: Node) { + let listState = useContext(ListStateContext)!; + + let nextNode = node.nextKey != null && listState.collection.getItem(node.nextKey); + if (node.prevKey == null || !nextNode || nextNode.type === 'separator' || (nextNode.type === 'loader' && nextNode.nextKey == null)) { + return null; + } + return (

From 875d9a3fc11a31ba58e43f044353e23a3e617ed1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 9 May 2025 15:41:32 -0700 Subject: [PATCH 89/90] marking the document as dirty when _minInvalidChildIndex is changing --- packages/@react-aria/collections/src/Document.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index d8e9423b573..71bd6cae007 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -105,6 +105,7 @@ export class BaseNode { private invalidateChildIndices(child: ElementNode): void { if (this._minInvalidChildIndex == null || !this._minInvalidChildIndex.isConnected || child.index < this._minInvalidChildIndex.index) { this._minInvalidChildIndex = child; + this.ownerDocument.markDirty(this); } } @@ -481,12 +482,6 @@ export class Document = BaseCollection> extend } // Next, update dirty collection nodes. - // TODO: when updateCollection is called here, shouldn't we be calling this.updateChildIndicies as well? Theoretically it should only update - // nodes from _minInvalidChildIndex onwards so the increase in dirtyNodes should be minimal. - // Is element.updateNode supposed to handle that (it currently assumes the index stored on the node is correct already). - // At the moment, without this call to updateChildIndicies, filtering an async combobox doesn't actually update the index values of the - // updated collection... - this.updateChildIndices(); for (let element of this.dirtyNodes) { if (element instanceof ElementNode) { if (element.isConnected && !element.isHidden) { From 9026acb7c8517cd6a72ada0b8f7218c12eaa932e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 9 May 2025 16:34:03 -0700 Subject: [PATCH 90/90] get rid of extra todos and obsolte test --- .../utils/src/useLoadMoreSentinel.ts | 1 - .../s2/chromatic/Combobox.stories.tsx | 1 - packages/react-aria-components/src/GridList.tsx | 2 +- packages/react-aria-components/src/ListBox.tsx | 2 +- packages/react-aria-components/src/Table.tsx | 3 +-- .../stories/Table.stories.tsx | 1 - .../react-aria-components/test/Table.test.js | 17 ----------------- 7 files changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index c2857d22597..d402fa7389e 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -26,7 +26,6 @@ export interface LoadMoreSentinelProps extends Omit * @default 1 */ scrollOffset?: number - // TODO: Maybe include a scrollRef option so the user can provide the scrollParent to compare against instead of having us look it up } export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index 59d36c0fdf4..cac5d8127b4 100644 --- a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx @@ -83,7 +83,6 @@ export const WithEmptyState = { } }; -// TODO: this one is probably not great for chromatic since it has the spinner, check if ignoreSelectors works for it export const WithInitialLoading = { ...EmptyCombobox, args: { diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 93c8a23d136..191cb9a2612 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -532,7 +532,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu return ( <> - {/* TODO: Alway render the sentinel. Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */}
diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 5002c4b5b2d..098758e46f9 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -509,7 +509,7 @@ export const UNSTABLE_ListBoxLoadingSentinel = createLeafComponent('loader', fun return ( <> - {/* TODO: Alway render the sentinel. Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */}
diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index aca00d6eef2..77adf122489 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1397,8 +1397,7 @@ export const UNSTABLE_TableLoadingSentinel = createLeafComponent('loader', funct return ( <> - {/* TODO weird structure? Renders a extra row but we hide via inert so maybe ok? */} - {/* TODO: Will need to figure out how best to position this in cases where the user is using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} {/* @ts-ignore - compatibility with React < 19 */} diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 34a37ffc52f..b1fcd48578c 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -844,7 +844,6 @@ function VirtualizedTableWithEmptyState(args) { Baz renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 0d8cf70d593..08e6e2fc278 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -2003,23 +2003,6 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(1); }); - // TODO: decide if we want to allow customization for this (I assume we will) - it.skip('allows the user to customize the scrollOffset required to trigger onLoadMore', function () { - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); - jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); - - let tree = render(); - - let scrollView = tree.getByTestId('scrollRegion'); - expect(onLoadMore).toHaveBeenCalledTimes(0); - - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - expect(onLoadMore).toHaveBeenCalledTimes(1); - }); - describe('virtualized', () => { let items = []; for (let i = 1; i <= 50; i++) {