diff --git a/package.json b/package.json index 46e748717cf..6b975b0b812 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-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-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 23717073b14..71bd6cae007 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -103,8 +103,9 @@ 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; + this.ownerDocument.markDirty(this); } } @@ -156,8 +157,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) { @@ -167,7 +171,7 @@ export class BaseNode { referenceNode.previousSibling = newNode; newNode.parentNode = referenceNode.parentNode; - this.invalidateChildIndices(referenceNode); + this.invalidateChildIndices(newNode); if (this.isConnected) { this.ownerDocument.queueUpdate(); } @@ -177,7 +181,7 @@ export class BaseNode { if (child.parentNode !== this || !this.ownerDocument.isMounted) { return; } - + if (child.nextSibling) { this.invalidateChildIndices(child.nextSibling); child.nextSibling.previousSibling = child.previousSibling; @@ -285,7 +289,7 @@ export class ElementNode extends BaseNode { this.node = this.node.clone(); this.isMutated = true; } - + this.ownerDocument.markDirty(this); return this.node; } @@ -505,7 +509,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/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 9db58b2ee24..a567ea89667 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -45,8 +45,11 @@ export {useEffectEvent} from './useEffectEvent'; export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; +export {UNSTABLE_useLoadMoreSentinel} from './useLoadMoreSentinel'; export {inertValue} from './inertValue'; 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 new file mode 100644 index 00000000000..d402fa7389e --- /dev/null +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -0,0 +1,63 @@ +/* + * 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 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 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 + * 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 +} + +export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { + let {collection, onLoadMore, scrollOffset = 1} = props; + + 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) { + // 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(); + } + } + }); + + useLayoutEffect(() => { + if (ref.current) { + // 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); + } + + return () => { + if (sentinelObserver.current) { + sentinelObserver.current.disconnect(); + } + }; + }, [collection, triggerLoadMore, ref, scrollOffset]); +} diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 532dfa59707..0b94ba31c09 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 [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. @@ -209,7 +209,11 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject fn()); + // This is so we update size in a separate render but within the same act. Needs to be setState instead of refs + // due to strict mode. + setUpdate({}); + lastContentSize.current = contentSize; + return; } else { queueMicrotask(() => updateSize(flushSync)); } @@ -218,6 +222,11 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { + updateSize(fn => fn()); + }, [update]); + let onResize = useCallback(() => { updateSize(flushSync); }, [updateSize]); diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index a7d43aadb4d..cac5d8127b4 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,89 @@ 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'); + } +}; + +export const WithInitialLoading = { + ...EmptyCombobox, + args: { + loadingState: 'loading', + label: 'Initial 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', + label: 'Loading more' + }, + 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, + args: { + ...AsyncComboBoxStory.args, + delay: 2000 + }, + 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, + args: { + ...AsyncComboBoxStory.args, + delay: 2000 + }, + 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}); + + await waitFor(() => { + expect(within(listbox).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx index 6e2cfab7311..43d62aa828d 100644 --- a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx @@ -10,15 +10,17 @@ * 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, 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' @@ -68,3 +70,58 @@ 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, + args: { + ...AsyncPickerStory.args, + delay: 2000 + }, + 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/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/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index f12685c10a1..ef391e46306 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", @@ -36,4 +37,4 @@ "toast.clearAll": "Clear all", "toast.collapse": "Collapse", "toast.showAll": "Show all" -} \ No newline at end of file +} 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/package.json b/packages/@react-spectrum/s2/package.json index c631e2beaf8..54b255eac76 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -122,7 +122,8 @@ "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", "@testing-library/user-event": "^14.0.0", diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index 39e6800f4fc..2ddd6388c08 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'; @@ -28,10 +30,10 @@ import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-u import {ImageCoordinator} from './ImageCoordinator'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useEffectEvent, useLayoutEffect, useLoadMore, useResizeObserver} from '@react-aria/utils'; +import {useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface CardViewProps 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 +182,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 +191,19 @@ 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, + items, + ...otherProps} = props; let domRef = useDOMRef(ref); let innerRef = useRef(null); let scrollRef = props.renderActionBar ? innerRef : domRef; @@ -224,16 +238,34 @@ 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}); + let renderer; + let cardLoadingSentinel = ( + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {cardLoadingSentinel} + + ); + } else { + renderer = ( + <> + {children} + {cardLoadingSentinel} + + ); + } + let cardView = ( @@ -242,6 +274,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca (!props.renderActionBar ? UNSAFE_className : '') + cardViewStyles({...renderProps, isLoading: props.loadingState === 'loading'}, !props.renderActionBar ? styles : undefined)}> - {children} + {renderer} diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 7598a0d163b..5bc65a2f54a 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -17,6 +17,8 @@ import { PopoverProps as AriaPopoverProps, Button, ButtonRenderProps, + Collection, + ComboBoxStateContext, ContextValue, InputContext, ListBox, @@ -24,10 +26,13 @@ import { ListBoxItemProps, ListBoxProps, ListLayout, + ListStateContext, Provider, SectionProps, + UNSTABLE_ListBoxLoadingSentinel, Virtualizer } from 'react-aria-components'; +import {AsyncLoadable, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import {centerPadding, control, controlBorderRadius, controlFont, controlSize, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -41,20 +46,25 @@ import { } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, 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 {createLeafComponent} from '@react-aria/collections'; 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 {mergeRefs, useResizeObserver} from '@react-aria/utils'; +// @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'; +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 { @@ -66,17 +76,18 @@ 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, HelpTextProps, Pick, 'items'>, - Pick { + Pick, + Pick { /** 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' */ @@ -88,7 +99,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); @@ -143,6 +156,51 @@ 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({ + height: { + size: { + S: 24, + M: 32, + L: 40, + XL: 48 + } + }, + font: { + size: { + S: 'ui-sm', + M: 'ui', + L: 'ui-lg', + XL: 'ui-xl' + } + }, + display: 'flex', + alignItems: 'center', + paddingStart: 'edge-to-text' +}); + export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ width: 'full', boxSizing: 'border-box', @@ -222,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)}]`, @@ -248,6 +303,26 @@ const dividerStyle = style({ width: 'full' }); +// 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'}); /** @@ -255,75 +330,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} - - - - - - + )} ); @@ -499,7 +423,284 @@ export function ComboBoxSection(props: ComboBoxSectionProps ); } -export const Divider = /*#__PURE__*/ createLeafComponent('separator', function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef) { +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, + onLoadMore + } = 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 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]); + + let inputValue = state?.inputValue; + let lastInputValue = useRef(inputValue); + useEffect(() => { + if (isLoadingOrFiltering && !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 (!isLoadingOrFiltering) { + // 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; + }, [isLoadingOrFiltering, showLoading, inputValue]); + + useEffect(() => { + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + timeout.current = null; + }; + }, []); + + let renderer; + let listBoxLoadingCircle = ( + + + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {listBoxLoadingCircle} + + ); + } 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} + {listBoxLoadingCircle} + + ); + } + let scale = useScale(); + + return ( + <> + + + {label} + + + + {ctx => ( + + + + )} + + {isInvalid && } + {showFieldSpinner && ( + + )} + + + + {errorMessage} + + + + + ( + + {loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')} + + )} + items={items} + className={listbox({size})}> + {renderer} + + + + + + + ); +}); + +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 (

diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index 3d3033db253..db140dbeb5b 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -18,6 +18,7 @@ import { SelectRenderProps as AriaSelectRenderProps, Button, ButtonRenderProps, + Collection, ContextValue, ListBox, ListBoxItem, @@ -26,9 +27,12 @@ import { ListLayout, Provider, SectionProps, + SelectStateContext, SelectValue, + UNSTABLE_ListBoxLoadingSentinel, Virtualizer } from 'react-aria-components'; +import {AsyncLoadable, FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { @@ -42,18 +46,19 @@ import { import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {control, controlBorderRadius, controlFont, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {createHideableComponent} from '@react-aria/collections'; import { Divider, listbox, listboxHeader, - listboxItem + listboxItem, + LOADER_ROW_HEIGHTS } from './ComboBox'; import { FieldErrorIcon, FieldLabel, HelpText } from './Field'; -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'; @@ -64,14 +69,15 @@ import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {PressResponder} from '@react-aria/interactions'; import {pressScale} from './pressScale'; +import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; import React, {createContext, forwardRef, ReactNode, useContext, useRef, useState} from 'react'; import {useFocusableRef} from '@react-spectrum/utils'; -import {useGlobalListeners} from '@react-aria/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 { /** * The size of the Picker. @@ -93,7 +99,9 @@ export interface PickerProps extends SpectrumLabelableProps, HelpTextProps, Pick, 'items'>, - Pick { + Pick, + AsyncLoadable + { /** The contents of the collection. */ children: ReactNode | ((item: T) => ReactNode), /** @@ -222,6 +230,29 @@ const iconStyles = style({ '--iconPrimary': { type: 'fill', value: 'currentColor' + }, + color: { + isInitialLoad: 'disabled' + } +}); + +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 + } } }); @@ -255,6 +286,8 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick UNSAFE_style, placeholder = stringFormatter.format('picker.placeholder'), isQuiet, + isLoading, + onLoadMore, ...pickerProps } = props; @@ -270,9 +303,41 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick menuOffset = 8; } + let renderer; + let spinnerId = useSlotId([isLoading]); + + let listBoxLoadingCircle = ( + + + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {listBoxLoadingCircle} + + ); + } else { + renderer = ( + <> + {children} + {listBoxLoadingCircle} + + ); + } + let scale = useScale(); + return ( + buttonRef={domRef} + loadingCircle={ + + } /> + estimatedRowHeight: 32, + estimatedHeadingHeight: 50, + padding: 8, + loaderHeight: LOADER_ROW_HEIGHTS[size][scale]}}> - {children} + {renderer} @@ -361,11 +434,29 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick ); }); -interface PickerButtonInnerProps extends PickerStyleProps, Omit { +function PickerProgressCircle(props) { + let { + id, + size, + 'aria-label': ariaLabel + } = props; + return ( + + ); +} + +interface PickerButtonInnerProps extends PickerStyleProps, Omit, Pick, 'isLoading'> { + loadingCircle: ReactNode, 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, @@ -373,8 +464,13 @@ function PickerButton(props: PickerButtonInnerProps) { size, isInvalid, isDisabled, + isLoading, + loadingCircle, buttonRef } = 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; // 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 @@ -437,12 +533,11 @@ function PickerButton(props: PickerButtonInnerProps) { ); }} - {isInvalid && ( - - )} + {isInvalid && } + {isInitialLoad && !isOpen && loadingCircle} + className={iconStyles({isInitialLoad})} /> {isFocusVisible && isQuiet && } {isInvalid && !isDisabled && !isQuiet && // @ts-ignore known limitation detecting functions from the theme @@ -453,7 +548,7 @@ function PickerButton(props: PickerButtonInnerProps) { ); -} +}); export interface PickerItemProps extends Omit, StyleProps { children: ReactNode diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index eeaf4ef075f..a259d3cf464 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -39,7 +39,7 @@ import { TableBodyRenderProps, TableLayout, TableRenderProps, - UNSTABLE_TableLoadingIndicator, + UNSTABLE_TableLoadingSentinel, useSlottedContext, useTableOptions, Virtualizer @@ -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 } @@ -200,7 +199,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; } @@ -226,7 +227,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,11 +273,11 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re overflowMode = 'truncate', styles, loadingState, - onLoadMore, onResize: propsOnResize, onResizeStart: propsOnResizeStart, onResizeEnd: propsOnResizeEnd, onAction, + onLoadMore, ...otherProps } = props; @@ -298,17 +300,12 @@ 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 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}); @@ -380,18 +377,22 @@ 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 + // 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 = ( - +
-
+ ); // If the user is rendering their rows in dynamic fashion, wrap their render function in Collection so we can inject @@ -404,19 +405,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-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index e1f931163fe..abebee6cfde 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 = { } }; - function VirtualizedCombobox(props) { let items: IExampleItem[] = []; for (let i = 0; i < 10000; i++) { @@ -207,3 +207,93 @@ 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, args.delay)); + 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', + delay: 50 + }, + 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 = { + render: (args) => ( + + {[]} + + ), + args: Example.args, + parameters: { + docs: { + disable: true + } + } +}; diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 8b514fc9b6e..852f6c5b924 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, @@ -223,3 +224,69 @@ 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, args.delay)); + 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, + label: 'Star Wars Character', + delay: 50 + }, + 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-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 new file mode 100644 index 00000000000..75e8ac8a7ec --- /dev/null +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -0,0 +1,166 @@ +/* + * 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. + */ + +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(); + function DynamicCombobox(props) { + let {items, loadingState, 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); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + user = userEvent.setup({delay: null, pointerMap}); + }); + + 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(); + }); + + it('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 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();}); + // 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('should omit the loader 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.'); + }); + + 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'}, {name: 'Chocolate Mint'}, {name: 'Chocolate Chip Cookie Dough'}, {name: 'Chocolate Chip'}]; + 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..db41b9a5038 --- /dev/null +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -0,0 +1,121 @@ +/* + * 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); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('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('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-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++; } } diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 11c9e3d72c8..b1df3f29dd7 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/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index dbdbe65b112..7930e10a2e4 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 { @@ -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; @@ -268,15 +272,28 @@ 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 + if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') { + let loader = this.buildChild(lastNode, this.padding, y, null); + nodes.push(loader); + y = loader.layoutInfo.rect.maxY; + } break; } } y -= this.gap; - y += this.padding; + y += isEmptyOrLoading ? 0 : this.padding; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); return nodes; } @@ -327,7 +344,9 @@ 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; + // 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-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 68a92183a68..a04d111ad0f 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'; @@ -255,7 +255,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; @@ -271,13 +272,32 @@ 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 + if (lastNode?.type === 'loader' && children.at(-1)?.layoutInfo.type !== 'loader') { + 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; } } - 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; @@ -446,6 +466,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-stately/layout/src/WaterfallLayout.ts b/packages/@react-stately/layout/src/WaterfallLayout.ts index 6e7b91ef421..01704daa3f1 100644 --- a/packages/@react-stately/layout/src/WaterfallLayout.ts +++ b/packages/@react-stately/layout/src/WaterfallLayout.ts @@ -140,8 +140,9 @@ export class WaterfallLayout extends Omit, 'children'>, CollectionStateBase {} @@ -62,6 +62,7 @@ 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]); return { ...validationState, @@ -70,13 +71,13 @@ export function useSelectState(props: SelectStateOptions): focusStrategy, open(focusStrategy: FocusStrategy | null = null) { // Don't open if the collection is empty. - if (listState.collection.size !== 0) { + if (!isEmpty) { setFocusStrategy(focusStrategy); triggerState.open(); } }, toggle(focusStrategy: FocusStrategy | null = null) { - if (listState.collection.size !== 0) { + if (!isEmpty) { setFocusStrategy(focusStrategy); triggerState.toggle(); } diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index 09e660dd5a7..f768f344e01 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -310,7 +310,7 @@ export class Virtualizer { 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; } 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/example/index.css b/packages/react-aria-components/example/index.css index 3a56707c261..ecc63e41e86 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -72,6 +72,9 @@ html { .item { padding: 2px 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; outline: none; cursor: default; color: black; @@ -474,3 +477,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/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index a3a0fc78ea1..191cb9a2612 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -14,11 +14,11 @@ 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, 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'; @@ -194,9 +194,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, @@ -211,10 +213,11 @@ function GridListInner({props, collection, gridListRef: ref}: let emptyState: ReactNode = null; let emptyStatePropOverrides: HTMLAttributes | null = null; - if (state.collection.size === 0 && props.renderEmptyState) { + + if (isEmpty && props.renderEmptyState) { let content = props.renderEmptyState(renderValues); emptyState = ( -
+
{content}
@@ -232,7 +235,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}> @@ -493,3 +496,61 @@ function RootDropIndicator() {
); } + +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: 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(() => ({ + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [onLoadMore, scrollOffset, state?.collection]); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + + let renderProps = useRenderProps({ + ...otherProps, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-GridListLoadingIndicator', + values: null + }); + + return ( + <> + {/* 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 */} +
+
+
+ {isLoading && renderProps.children && ( +
+
+ {renderProps.children} +
+
+ )} + + ); +}); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 246c9d9fc19..098758e46f9 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -13,11 +13,11 @@ import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; 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, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; +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, mergeRefs, 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'; @@ -92,7 +92,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. - if (state) { return ; } @@ -203,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', @@ -219,7 +219,7 @@ function ListBoxInner({state: inputState, props, listBoxRef}: }); let emptyState: JSX.Element | null = null; - if (state.collection.size === 0 && 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'} @@ -464,3 +464,67 @@ function ListBoxDropIndicator(props: ListBoxDropIndicatorProps, ref: ForwardedRe } const ListBoxDropIndicatorForwardRef = forwardRef(ListBoxDropIndicator); + +export interface ListBoxLoadingSentinelProps 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_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(() => ({ + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [onLoadMore, scrollOffset, state?.collection]); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + let renderProps = useRenderProps({ + ...otherProps, + id: undefined, + children: item.rendered, + defaultClassName: 'react-aria-ListBoxLoadingIndicator', + values: null + }); + + let optionProps = { + // For Android talkback + tabIndex: -1 + }; + + if (isVirtualized) { + optionProps['aria-posinset'] = item.index + 1; + optionProps['aria-setsize'] = state.collection.size; + } + + return ( + <> + {/* 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 */} +
+
+
+ {isLoading && renderProps.children && ( +
+ {renderProps.children} +
+ )} + + ); +}); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index bdf3c932b24..77adf122489 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, 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'; @@ -931,9 +931,10 @@ export const TableBody = /*#__PURE__*/ createBranchComponent('tablebody', , 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_TableLoadingIndicator = 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; let numColumns = state.collection.columns.length; + let sentinelRef = useRef(null); + let memoedLoadMoreProps = useMemo(() => ({ + onLoadMore, + collection: state?.collection, + sentinelRef, + scrollOffset + }), [onLoadMore, scrollOffset, state?.collection]); + UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + let renderProps = useRenderProps({ - ...props, + ...otherProps, id: undefined, children: item.rendered, defaultClassName: 'react-aria-TableLoadingIndicator', @@ -1369,7 +1388,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 { @@ -1378,15 +1397,22 @@ export const UNSTABLE_TableLoadingIndicator = createLeafComponent('loader', func return ( <> - - - {renderProps.children} - + {/* 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 */} + + + {isLoading && renderProps.children && ( + + + {renderProps.children} + + + )} ); }); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 730a28d2d5e..62858f29462 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 {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 {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'; diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 92851e9d7f9..b49343e41c8 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {Button, ComboBox, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components'; -import {MyListBoxItem} from './utils'; +import {Button, Collection, ComboBox, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components'; +import {LoadingSpinner, MyListBoxItem} from './utils'; import React, {useMemo, useState} from 'react'; import styles from '../example/index.css'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; import {useAsyncList} from 'react-stately'; export default { @@ -236,3 +237,76 @@ export const VirtualizedComboBox = () => { ); }; + +let renderEmptyState = () => { + return ( +
+ No results +
+ ); +}; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +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, args.delay)); + 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} renderEmptyState={renderEmptyState}> + + {item => {item.name}} + + + + + +
+ ); +}; + +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 a20e4edd669..2f246ab46a8 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -15,6 +15,7 @@ import { Button, Checkbox, CheckboxProps, + Collection, Dialog, DialogTrigger, DropIndicator, @@ -35,9 +36,11 @@ import { Virtualizer } from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; -import {Key, useListData} from 'react-stately'; +import {Key, useAsyncList, useListData} from 'react-stately'; +import {LoadingSpinner} from './utils'; import React, {useState} from 'react'; import styles from '../example/index.css'; +import {UNSTABLE_GridListLoadingSentinel} from '../src/GridList'; export default { title: 'React Aria Components' @@ -199,6 +202,121 @@ export function VirtualizedGridListGrid() { ); } +let renderEmptyState = ({isLoading}) => { + return ( +
+ {isLoading ? : 'No results'} +
+ ); +}; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const MyGridListLoaderIndicator = (props) => { + return ( + + + + ); +}; + +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, args.delay)); + 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 ( + renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + {item.name} + )} + + + + ); +}; + +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, args.delay)); + 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 ( + + renderEmptyState({isLoading: list.isLoading})}> + + {item => {item.name}} + + + + + ); +}; + +AsyncGridListVirtualized.story = { + args: { + delay: 50 + } +}; + export function TagGroupInsideGridList() { return ( + })}> {section => ( @@ -434,3 +434,161 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}) {
); } + +let renderEmptyState = ({isLoading}) => { + return ( +
+ {isLoading ? : 'No results'} +
+ ); +}; + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const MyListBoxLoaderIndicator = (props) => { + let {orientation, ...otherProps} = props; + return ( + + + + ); +}; + +export const AsyncListBox = (args) => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + 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 { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + + {item.name} + + )} + + + + ); +}; + +AsyncListBox.story = { + args: { + orientation: 'horizontal', + delay: 50 + }, + argTypes: { + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'] + } + } +}; + +export const AsyncListBoxVirtualized = (args) => { + let list = useAsyncList({ + async load({signal, cursor, filterText}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + 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 { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + renderEmptyState({isLoading: list.isLoading})}> + + {(item: Character) => ( + + {item.name} + + )} + + + + + ); +}; + +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 739749126b1..9b42c5ba954 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 {MyListBoxItem} from './utils'; +import {Button, Collection, Label, ListBox, ListLayout, OverlayArrow, Popover, Select, SelectValue, Virtualizer} from 'react-aria-components'; +import {LoadingSpinner, MyListBoxItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; +import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox'; +import {useAsyncList} from 'react-stately'; export default { title: 'React Aria Components' @@ -101,3 +103,74 @@ export const VirtualizedSelect = () => ( ); + +interface Character { + name: string, + height: number, + mass: number, + birth_year: number +} + +const MyListBoxLoaderIndicator = (props) => { + return ( + + + + ); +}; + +export const AsyncVirtualizedCollectionRenderSelect = (args) => { + 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, args.delay)); + 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 ( + + ); +}; + +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 28f110b5482..b1fcd48578c 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -13,12 +13,11 @@ 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, {startTransition, Suspense, useMemo, useRef, useState} from 'react'; +import {LoadingSpinner, MyMenuItem} from './utils'; +import React, {startTransition, Suspense, useState} from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_TableLoadingIndicator} from '../src/Table'; +import {UNSTABLE_TableLoadingSentinel} from '../src/Table'; import {useAsyncList, useListData} from 'react-stately'; -import {useLoadMore} from '@react-aria/utils'; export default { title: 'React Aria Components', @@ -465,7 +464,7 @@ export const DndTable = (props: DndTableProps) => { )} - {props.isLoading && list.items.length > 0 && } + ); @@ -533,27 +532,26 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => { ); }; -const MyTableLoadingIndicator = ({tableWidth = 400}) => { +const MyTableLoadingIndicator = (props) => { + let {tableWidth = 400, ...otherProps} = props; return ( // 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 - - + + + ); }; function MyTableBody(props) { - let {rows, children, isLoadingMore, tableWidth, ...otherProps} = props; + let {rows, children, isLoading, onLoadMore, tableWidth, ...otherProps} = props; return ( {children} - {isLoadingMore && } + ); } @@ -566,7 +564,7 @@ const TableLoadingBodyWrapper = (args: {isLoadingMore: boolean}) => { {column.name} )} - + {(item) => ( {(column) => { @@ -592,7 +590,7 @@ function MyRow(props) { <> {/* Note that all the props are propagated from MyRow to Row, ensuring the id propagates */} - {props.isLoadingMore && } + ); } @@ -628,8 +626,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}) => { @@ -675,7 +673,7 @@ interface Character { birth_year: number } -const OnLoadMoreTable = () => { +const OnLoadMoreTable = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -683,9 +681,10 @@ const OnLoadMoreTable = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + 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 { items: json.results, cursor: json.next @@ -693,17 +692,8 @@ const OnLoadMoreTable = () => { } }); - 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 @@ -714,7 +704,8 @@ const OnLoadMoreTable = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading', tableWidth: 400})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={list.loadingState === 'loadingMore'} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -732,7 +723,10 @@ const OnLoadMoreTable = () => { export const OnLoadMoreTableStory = { render: OnLoadMoreTable, - name: 'onLoadMore table' + name: 'onLoadMore table', + args: { + delay: 50 + } }; export function VirtualizedTable() { @@ -850,7 +844,7 @@ function VirtualizedTableWithEmptyState(args) { Baz renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> {(item) => ( @@ -876,7 +870,7 @@ export const VirtualizedTableWithEmptyStateStory = { name: 'Virtualized Table With Empty State' }; -const OnLoadMoreTableVirtualized = () => { +const OnLoadMoreTableVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -884,7 +878,7 @@ const OnLoadMoreTableVirtualized = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + 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 { @@ -894,23 +888,15 @@ 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 ( -
+
Name Height @@ -919,7 +905,8 @@ const OnLoadMoreTableVirtualized = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={list.loadingState === 'loadingMore'} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -937,10 +924,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) { @@ -948,7 +938,7 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { } // Slow down load so progress circle can appear - await new Promise(resolve => setTimeout(resolve, 4000)); + 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 { @@ -958,22 +948,14 @@ 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 ( - +
@@ -984,7 +966,8 @@ const OnLoadMoreTableVirtualizedResizeWrapper = () => { renderEmptyLoader({isLoading: list.loadingState === 'loading'})} - isLoadingMore={list.loadingState === 'loadingMore'} + isLoading={list.loadingState === 'loadingMore'} + onLoadMore={list.loadMore} rows={list.items}> {(item) => ( @@ -1003,7 +986,15 @@ 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', + 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.' + } + } }; 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 ( + + + + + + + + + ); +}; 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); + }); }); diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c3a437229c4..61021742559 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,249 @@ 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 = (loadingState) => { + return ( + loadingState === 'loading' ?
loading
:
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', 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(); + + // Even if the gridlist is empty, providing isLoading will render the loader + tree.rerender(); + rows = gridListTester.rows; + 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 intersection is detected regardless of loading state', async () => { + let observer = setupIntersectionObserverMock({ + observe + }); + + let tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + observe.mockClear(); + + tree.rerender(); + 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); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let VirtualizedAsyncGridList = (props) => { + let {items, loadingState, onLoadMore, ...listBoxProps} = props; + return ( + + renderEmptyState(loadingState)}> + + {(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', 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'); + + tree.rerender(); + rows = gridListTester.rows; + 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('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/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 10b9370a4e7..7ca4f076d35 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,225 @@ 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.runAllTimers(); + 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).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + observe.mockClear(); + + tree.rerender(); + 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); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + + afterAll(() => { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + function 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... + // 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(); + 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 31a2f7bed0f..08e6e2fc278 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -10,15 +10,14 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, 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 {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, UNSTABLE_TableLoadingSentinel, 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'; @@ -1639,20 +1638,39 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows).toHaveLength(6); - let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + 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'); + }); + + 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'); + + 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]; - expect(loader).toHaveTextContent('Load more spinner'); + let loader = rows[6]; + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1673,8 +1691,9 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + let loader = rows[6]; + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1690,8 +1709,9 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - let loader = rows[5]; - expect(loader).toHaveTextContent('Load more spinner'); + let loader = rows[6]; + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); await user.tab(); expect(document.activeElement).toBe(rows[1]); @@ -1730,7 +1750,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(); @@ -1745,9 +1766,10 @@ describe('Table', () => { let {getAllByRole} = render(); let rows = getAllByRole('row'); - expect(rows).toHaveLength(4); - let loader = rows[3]; - expect(loader).toHaveTextContent('Load more spinner'); + expect(rows).toHaveLength(5); + let loader = rows[4]; + let spinner = within(loader).getByRole('progressbar'); + expect(spinner).toHaveAttribute('aria-label', 'loading'); let selectAll = getAllByRole('checkbox')[0]; expect(selectAll).toHaveAttribute('aria-label', 'Select All'); @@ -1764,10 +1786,11 @@ 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]; - expect(loader).toHaveTextContent('Load more spinner'); + let loader = rows[4]; + 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'); @@ -1812,29 +1835,26 @@ 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 - - {(item) => ( - - {item.foo} - {item.bar} - - )} + 'No results'}> + + {(item) => ( + + {item.foo} + {item.bar} + + )} + + +
spinner
+
@@ -1845,56 +1865,95 @@ describe('Table', () => { onLoadMore.mockRestore(); }); - it('should fire onLoadMore when scrolling near the bottom', function () { - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); - jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); + 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 tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); - let scrollView = tree.getByTestId('scrollRegion'); - expect(onLoadMore).toHaveBeenCalledTimes(0); + 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(); + }); - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + 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 + }); + + let tree = render(); expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(sentinel.nodeName).toBe('TD'); - scrollView.scrollTop = 76; - fireEvent.scroll(scrollView); + expect(onLoadMore).toHaveBeenCalledTimes(0); + 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 () { - 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 = 76; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + it('should only fire loadMore when intersection is detected regardless of loading state', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); + let tree = render(); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(0); + observe.mockClear(); - tree.rerender(); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + tree.rerender(); + expect(observe).toHaveBeenLastCalledWith(sentinel); expect(onLoadMore).toHaveBeenCalledTimes(1); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(2); }); 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); }); @@ -1944,77 +2003,144 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(1); }); - it('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(); + describe('virtualized', () => { + let items = []; + for (let i = 1; i <= 50; i++) { + items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); + } + let clientWidth, clientHeight; - let scrollView = tree.getByTestId('scrollRegion'); - expect(onLoadMore).toHaveBeenCalledTimes(0); + beforeAll(() => { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + beforeEach(() => { + act(() => {jest.runAllTimers();}); + }); - expect(onLoadMore).toHaveBeenCalledTimes(1); - }); + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); - it('works with virtualizer', function () { - let items = []; - for (let i = 0; i < 6; i++) { - items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); - } - function VirtualizedTableLoad() { - let scrollRef = useRef(null); - useLoadMore({onLoadMore}, scrollRef); + let VirtualizedTableLoad = (props) => { + let {items, loadingState, onLoadMore} = props; return ( - - + +
Foo Bar - - {item => ( - - {item.foo} - {item.bar} - - )} + loadingState === 'loading' ? 'loading' : 'No results'}> + + {(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} = render(); - - let scrollView = getByRole('grid'); - expect(onLoadMore).toHaveBeenCalledTimes(0); - - scrollView.scrollTop = 50; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + 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'); + + tree.rerender(); + rows = tableTester.rows; + 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('0px'); + }); - expect(onLoadMore).toHaveBeenCalledTimes(0); + it('should have the correct row indicies after loading more items', async () => { + 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}`); + } - scrollView.scrollTop = 76; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); + 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}`); + } - expect(onLoadMore).toHaveBeenCalledTimes(1); + 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}`); + } + } + }); }); }); @@ -2175,9 +2301,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); }); @@ -2187,9 +2313,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); }); @@ -2199,9 +2325,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); }); 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; +}); diff --git a/yarn.lock b/yarn.lock index 19cee46020c..bb9938cb266 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" @@ -2995,6 +3002,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" @@ -6726,20 +6742,6 @@ __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": version: 0.0.0-use.local resolution: "@react-aria/test-utils@workspace:packages/@react-aria/test-utils" @@ -7960,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.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" @@ -7972,6 +7974,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" @@ -9444,6 +9447,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" @@ -10176,6 +10186,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" @@ -10183,6 +10202,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" @@ -10911,6 +10942,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" @@ -11308,6 +11354,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" @@ -15892,6 +15948,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" @@ -15972,6 +16035,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" @@ -21393,6 +21463,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" @@ -21469,6 +21551,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" @@ -21533,6 +21622,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" @@ -21571,7 +21672,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: @@ -26929,6 +27030,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" @@ -27641,6 +27754,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"