diff --git a/package.json b/package.json index 0017e10be7f..0ea0c33fc9e 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@storybook/addon-themes": "^7.6.19", "@storybook/api": "^7.6.19", "@storybook/components": "^7.6.19", + "@storybook/jest": "^0.2.3", "@storybook/manager-api": "^7.6.19", "@storybook/preview": "^7.6.19", "@storybook/preview-api": "^7.6.19", diff --git a/packages/@react-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..c2857d22597 --- /dev/null +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -0,0 +1,64 @@ +/* + * 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 + // TODO: Maybe include a scrollRef option so the user can provide the scrollParent to compare against instead of having us look it up +} + +export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject): void { + 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..716baaa5022 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -98,7 +98,6 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); + let [, setUpdate] = useState({}); + let queuedUpdateSize = useRef(false); useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -209,12 +210,22 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject fn()); + // Queue call of updateSize to happen in a separate render but within the same act so that RAC virtualized ComboBoxes and Selects + // work properly + queuedUpdateSize.current = true; + setUpdate({}); + lastContentSize.current = contentSize; + return; } else { queueMicrotask(() => updateSize(flushSync)); } } + if (queuedUpdateSize.current) { + queuedUpdateSize.current = false; + updateSize(fn => fn()); + } + lastContentSize.current = contentSize; }); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 36421aaf844..81a1c47a9a0 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1025,6 +1025,9 @@ describe('Picker', function () { expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Empty'); + // TODO: this test (along with others in this suite) fails because we seem to be detecting that the button is being focused after the + // dropdown is opened, resulting in the dropdown closing due to useOverlay interactOutside logic + // Seems to specifically happen if the Picker has a selected item and the user tries to open the Picker await selectTester.selectOption({option: 'Zero'}); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenLastCalledWith('0'); diff --git a/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx b/packages/@react-spectrum/s2/chromatic/Combobox.stories.tsx index a7d43aadb4d..f9ae13d20c8 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,80 @@ export const WithCustomWidth = { ...CustomWidth, play: async (context) => await Static.play!(context) } as StoryObj; + +export const WithEmptyState = { + ...EmptyCombobox, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByText('No results'); + } +}; + +// TODO: this one is probably not great for chromatic since it has the spinner, check if ignoreSelectors works for it +export const WithInitialLoading = { + ...EmptyCombobox, + args: { + loadingState: 'loading' + }, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByText('Loading', {exact: false}); + } +}; + +export const WithLoadMore = { + ...Example, + args: { + loadingState: 'loadingMore' + }, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await within(listbox).findByRole('progressbar'); + } +}; + +export const AsyncResults = { + ...AsyncComboBoxStory, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + } +}; + +export const Filtering = { + ...AsyncComboBoxStory, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + + let combobox = await within(body).findByRole('combobox'); + await userEvent.type(combobox, 'R2'); + + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + + 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..5960a87cb58 100644 --- a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import {ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/Picker.stories'; +import {AsyncPickerStory, ContextualHelpExample, CustomWidth, Dynamic, Example, Sections, WithIcons} from '../stories/Picker.stories'; +import {expect} from '@storybook/jest'; import type {Meta, StoryObj} from '@storybook/react'; import {Picker} from '../src'; -import {userEvent, within} from '@storybook/testing-library'; +import {userEvent, waitFor, within} from '@storybook/testing-library'; const meta: Meta> = { component: Picker, @@ -68,3 +69,54 @@ export const ContextualHelp = { } }; +export const EmptyAndLoading = { + render: () => ( + + {[]} + + ), + play: async ({canvasElement}) => { + let body = canvasElement.ownerDocument.body; + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + expect(within(body).queryByRole('listbox')).toBeFalsy(); + } +}; + +export const AsyncResults = { + ...AsyncPickerStory, + play: async ({canvasElement}) => { + let body = canvasElement.ownerDocument.body; + await waitFor(() => { + expect(within(body).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + await userEvent.tab(); + + await waitFor(() => { + expect(within(body).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); + + await userEvent.keyboard('{ArrowDown}'); + let listbox = await within(body).findByRole('listbox'); + await waitFor(() => { + expect(within(listbox).getByText('Luke', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + + await waitFor(() => { + expect(within(listbox).getByRole('progressbar', {hidden: true})).toBeInTheDocument(); + }, {timeout: 5000}); + + await waitFor(() => { + expect(within(listbox).queryByRole('progressbar', {hidden: true})).toBeFalsy(); + }, {timeout: 5000}); + + await userEvent.keyboard('{PageDown}'); + + await waitFor(() => { + expect(within(listbox).getByText('Greedo', {exact: false})).toBeInTheDocument(); + }, {timeout: 5000}); + } +}; diff --git a/packages/@react-spectrum/s2/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..4e0adcb8e9b 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -123,6 +123,7 @@ "@adobe/spectrum-tokens": "^13.0.0-beta.56", "@parcel/macros": "^2.14.0", "@react-aria/test-utils": "1.0.0-alpha.3", + "@storybook/jest": "^0.2.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", @@ -136,6 +137,7 @@ "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", "@react-aria/overlays": "^3.27.0", + "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/packages/@react-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 1a755084b22..f14f44123f7 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -16,49 +16,58 @@ import { ListBoxSection as AriaListBoxSection, PopoverProps as AriaPopoverProps, Button, + Collection, + ComboBoxStateContext, ContextValue, InputContext, ListBox, ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, - SectionProps + SectionProps, + SeparatorContext, + SeparatorProps, + UNSTABLE_ListBoxLoadingSentinel, + useContextProps, + Virtualizer } from 'react-aria-components'; -import {baseColor, style} from '../style' with {type: 'macro'}; +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, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { checkmark, description, - Divider, icon, iconCenterWrapper, - label, - menuitem, - section, - sectionHeader, - sectionHeading + label } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; +import {createContext, CSSProperties, ElementType, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; -import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {createLeafComponent} from '@react-aria/collections'; +import {divider} from './Divider'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; +import {filterDOMProps, mergeRefs, useResizeObserver, useSlotId} from '@react-aria/utils'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; -import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; -import {menu} from './Picker'; -import {mergeRefs, useResizeObserver} from '@react-aria/utils'; -import {Placement} from 'react-aria'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {mergeStyles} from '../style/runtime'; +import {Placement, useSeparator} 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 { /** * The size of the Combobox. @@ -68,17 +77,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' */ @@ -90,7 +100,9 @@ export interface ComboBoxProps extends */ align?: 'start' | 'end', /** Width of the menu. By default, matches width of the trigger. Note that the minimum width of the dropdown is always equal to the trigger's width. */ - menuWidth?: number + menuWidth?: number, + /** The current loading state of the ComboBox. Determines whether or not the progress circle should be shown. */ + loadingState?: LoadingState } export const ComboBoxContext = createContext>, TextFieldRef>>(null); @@ -147,6 +159,186 @@ 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', + maxHeight: '[inherit]', + overflow: 'auto', + fontFamily: 'sans', + fontSize: 'control' +}); + +export let listboxItem = style({ + ...focusRing(), + boxSizing: 'border-box', + borderRadius: 'control', + font: 'control', + '--labelPadding': { + type: 'paddingTop', + value: centerPadding() + }, + paddingBottom: '--labelPadding', + backgroundColor: { // TODO: revisit color when I have access to dev mode again + default: { + default: 'transparent', + isFocused: baseColor('gray-100').isFocusVisible + } + }, + color: { + default: 'neutral', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + position: 'relative', + // each menu item should take up the entire width, the subgrid will handle within the item + gridColumnStart: 1, + gridColumnEnd: -1, + display: 'grid', + gridTemplateAreas: [ + '. checkmark icon label value keyboard descriptor .', + '. . . description . . . .' + ], + gridTemplateColumns: { + size: { + S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)], + M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)], + L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)], + XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)] + } + }, + gridTemplateRows: { + // min-content prevents second row from 'auto'ing to a size larger then 0 when empty + default: 'auto minmax(0, min-content)', + ':has([slot=description])': 'auto auto' + }, + rowGap: { + ':has([slot=description])': space(1) + }, + alignItems: 'baseline', + minHeight: 'control', + height: 'min', + textDecoration: 'none', + cursor: { + default: 'default', + isLink: 'pointer' + }, + transition: 'default' +}, getAllowedOverrides()); + +export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ + color: 'neutral', + boxSizing: 'border-box', + minHeight: 'control', + paddingY: centerPadding(), + marginX: { + size: { + S: '[calc(24 * 3 / 8)]', + M: '[calc(32 * 3 / 8)]', + L: '[calc(40 * 3 / 8)]', + XL: '[calc(48 * 3 / 8)]' + } + } +}); + +export let listboxHeading = style({ + fontSize: 'ui', + fontWeight: 'bold', + lineHeight: 'ui', + margin: 0 +}); + +// not sure why edgeToText won't work... +const separatorWrapper = style({ + display: { + ':is(:last-child > &)': 'none', + default: 'flex' + }, + // marginX: { + // size: { + // S: edgeToText(24), + // M: edgeToText(32), + // L: edgeToText(40), + // XL: edgeToText(48) + // } + // }, + marginX: { + size: { + S: '[calc(24 * 3 / 8)]', + M: '[calc(32 * 3 / 8)]', + L: '[calc(40 * 3 / 8)]', + XL: '[calc(48 * 3 / 8)]' + } + }, + height: 12 +}); + +// Not from any design, just following the sizing of the existing rows +export const LOADER_ROW_HEIGHTS = { + S: { + medium: 24, + large: 30 + }, + M: { + medium: 32, + large: 40 + }, + L: { + medium: 40, + large: 50 + }, + XL: { + medium: 48, + large: 60 + } +}; + let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); /** @@ -154,75 +346,22 @@ let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({siz */ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function ComboBox(props: ComboBoxProps, ref: Ref) { [props, ref] = useSpectrumContextProps(props, ref, ComboBoxContext); - let inputRef = useRef(null); - let domRef = useRef(null); - let buttonRef = useRef(null); + let formContext = useContext(FormContext); props = useFormProps(props); let { - direction = 'bottom', - align = 'start', - shouldFlip = true, - menuWidth, - label, - description: descriptionMessage, - errorMessage, - children, - items, size = 'M', labelPosition = 'top', - labelAlign = 'start', - necessityIndicator, UNSAFE_className = '', UNSAFE_style, - ...pickerProps + loadingState, + ...comboBoxProps } = props; - // Expose imperative interface for ref - useImperativeHandle(ref, () => ({ - ...createFocusableRef(domRef, inputRef), - select() { - if (inputRef.current) { - inputRef.current.select(); - } - }, - getInputElement() { - return inputRef.current; - } - })); - - // Better way to encode this into a style? need to account for flipping - let menuOffset: number; - if (size === 'S') { - menuOffset = 6; - } else if (size === 'M') { - menuOffset = 6; - } else if (size === 'L') { - menuOffset = 7; - } else { - menuOffset = 8; - } - - let triggerRef = useRef(null); - // Make menu width match input + button - let [triggerWidth, setTriggerWidth] = useState(null); - let onResize = useCallback(() => { - if (triggerRef.current) { - let inputRect = triggerRef.current.getBoundingClientRect(); - let minX = inputRect.left; - let maxX = inputRect.right; - setTriggerWidth((maxX - minX) + 'px'); - } - }, [triggerRef, setTriggerWidth]); - - useResizeObserver({ - ref: triggerRef, - onResize: onResize - }); - return ( {({isDisabled, isOpen, isRequired, isInvalid}) => ( - <> - - - {label} - - - - {ctx => ( - - - - )} - - {isInvalid && } - - - - {errorMessage} - - - - - {children} - - - - - + )} ); }); - export interface ComboBoxItemProps extends Omit, StyleProps { children: ReactNode } @@ -348,7 +396,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( @@ -383,11 +431,326 @@ export function ComboBoxSection(props: ComboBoxSectionProps return ( <> + {...props}> {props.children} - + ); } + +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 isEmptyOrLoading = state?.collection?.size === 0 || (state?.collection.size === 1 && state.collection.getItem(state.collection.getFirstKey()!)!.type === 'loader'); + 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 function Divider(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL' | undefined}): ReactNode { + return ( + + ); +} + +const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, SeparatorContext); + + let {elementType, orientation, size, style, className, slot, ...otherProps} = props; + let Element = (elementType as ElementType) || 'hr'; + if (Element === 'hr' && orientation === 'vertical') { + Element = 'div'; + } + + let {separatorProps} = useSeparator({ + ...otherProps, + elementType, + orientation + }); + + return ( +
+ +
+ ); +}); diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index ea3a15d66d5..07fb8003f69 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -18,38 +18,47 @@ import { SelectRenderProps as AriaSelectRenderProps, Button, ButtonRenderProps, + Collection, ContextValue, ListBox, ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, SectionProps, - SelectValue + SelectRenderProps, + SelectStateContext, + SelectValue, + UNSTABLE_ListBoxLoadingSentinel, + Virtualizer } from 'react-aria-components'; +import {AsyncLoadable, FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { checkmark, description, - Divider, icon, iconCenterWrapper, - label, - menuitem, - section, - sectionHeader, - sectionHeading + label } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; +import { + Divider, + listbox, + listboxHeader, + listboxHeading, + listboxItem, + LOADER_ROW_HEIGHTS +} from './ComboBox'; import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { FieldErrorIcon, FieldLabel, HelpText } from './Field'; -import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; @@ -60,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. @@ -89,7 +99,9 @@ export interface PickerProps extends SpectrumLabelableProps, HelpTextProps, Pick, 'items'>, - Pick { + Pick, + AsyncLoadable + { /** The contents of the collection. */ children: ReactNode | ((item: T) => ReactNode), /** @@ -226,6 +238,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 + } } }); @@ -259,6 +294,8 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick UNSAFE_style, placeholder = stringFormatter.format('picker.placeholder'), isQuiet, + isLoading, + onLoadMore, ...pickerProps } = props; @@ -289,9 +326,49 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }, {once: true, capture: true}); }; + let renderer; + let spinnerId = useSlotId([isLoading]); + let loadingCircle = ( + + ); + + let listBoxLoadingCircle = ( + + {loadingCircle} + + ); + + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {listBoxLoadingCircle} + + ); + } else { + renderer = ( + <> + {children} + {listBoxLoadingCircle} + + ); + } + let scale = useScale(); + return ( {(renderProps) => ( - <> - * {display: none;}')}> - {({defaultChildren}) => { - return ( - - {defaultChildren} - - ); - }} - - {isInvalid && ( - - )} - - {isFocusVisible && isQuiet && } - {isInvalid && !isDisabled && !isQuiet && - // @ts-ignore known limitation detecting functions from the theme -
- } - + + } /> )} @@ -403,19 +454,28 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick })(props)}> - - {children} - + + + {renderer} + + @@ -446,7 +506,7 @@ export function PickerItem(props: PickerItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( @@ -489,11 +549,66 @@ export function PickerSection(props: PickerSectionProps): R return ( <> + {...props}> {props.children} - + + + ); +} + +interface PickerButtonInnerProps extends Pick, 'size' | 'isLoading' | 'isQuiet'>, Pick, ButtonRenderProps { + loadingCircle: ReactNode +} + +function PickerButtonInner(props: PickerButtonInnerProps) { + let {size, isLoading, isQuiet, isInvalid, isDisabled, isFocusVisible, isOpen, loadingCircle} = props; + let state = useContext(SelectStateContext); + // If it is the initial load, the collection either hasn't been formed or only has the loader so apply the disabled style + let isInitialLoad = (state?.collection.size == null || state?.collection.size <= 1) && isLoading; + + return ( + <> + * {display: none;}')}> + {({defaultChildren}) => { + return ( + + {defaultChildren} + + ); + }} + + {isInvalid && } + {isInitialLoad && !isOpen && loadingCircle} + + {isFocusVisible && isQuiet && } + {isInvalid && !isDisabled && !isQuiet && + // @ts-ignore known limitation detecting functions from the theme +
+ } ); } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index eeb558e975b..65e9aefcd8a 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -38,7 +38,7 @@ import { TableBodyRenderProps, TableLayout, TableRenderProps, - UNSTABLE_TableLoadingIndicator, + UNSTABLE_TableLoadingSentinel, useSlottedContext, useTableOptions, Virtualizer @@ -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 } @@ -197,7 +196,9 @@ export class S2TableLayout extends TableLayout { let {children, layoutInfo} = body; // TableLayout's buildCollection always sets the body width to the max width between the header width, but // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty - if (children?.length === 0) { + // TODO: we may want to adjust RAC layouts to do something simlar? Current users of RAC table will probably run into something similar + let isEmptyOrLoading = children?.length === 0 || (children?.length === 1 && children[0].layoutInfo.type === 'loader'); + if (isEmptyOrLoading) { layoutInfo.rect.width = this.virtualizer!.visibleRect.width - 80; } @@ -223,7 +224,8 @@ export class S2TableLayout extends TableLayout { // Needs overflow for sticky loader layoutInfo.allowOverflow = true; // If loading or empty, we'll want the body to be sticky and centered - if (children?.length === 0) { + let isEmptyOrLoading = children?.length === 0 || (children?.length === 1 && children[0].layoutInfo.type === 'loader'); + if (isEmptyOrLoading) { layoutInfo.rect = new Rect(40, 40, this.virtualizer!.visibleRect.width - 80, this.virtualizer!.visibleRect.height - 80); layoutInfo.isSticky = true; } @@ -268,11 +270,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; @@ -295,17 +297,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}); @@ -377,18 +374,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 @@ -401,19 +402,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 802b7971840..c1b32d913a3 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,6 +99,27 @@ export const Dynamic: Story = { } }; +function VirtualizedCombobox(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; export const WithIcons: Story = { render: (args) => ( @@ -185,3 +207,92 @@ 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 0520c236b9e..ac9e49dc184 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, @@ -131,6 +132,28 @@ export const WithIcons: Story = { } }; +function VirtualizedPicker(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + const ValidationRender = (props) => (
@@ -201,3 +224,68 @@ 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, + 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..db59e8754c8 --- /dev/null +++ b/packages/@react-spectrum/s2/test/Combobox.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; +import {ComboBox, ComboBoxItem} from '../src'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; + +describe('Combobox', () => { + let testUtilUser = new User(); + + beforeAll(function () { + jest.useFakeTimers(); + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('should render the sentinel when the combobox is empty', async () => { + let tree = render( + + {[]} + + ); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + expect(comboboxTester.listbox).toBeFalsy(); + comboboxTester.setInteractionType('mouse'); + await comboboxTester.open(); + + expect(comboboxTester.options()).toHaveLength(1); + expect(within(comboboxTester.listbox!).getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + 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); + }); +}); diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index 1cb029b1b47..b4d1452b71f 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -33,7 +33,8 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 14c8e0865b4..176e5c1a997 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -120,8 +120,12 @@ export class GridLayout exte let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)); this.gap = new Size(horizontalSpacing, minSpace.height); - let rows = Math.ceil(this.virtualizer!.collection.size / numColumns); - let iterator = this.virtualizer!.collection[Symbol.iterator](); + let collection = this.virtualizer!.collection; + // Make sure to set rows to 0 if we performing a first time load or are rendering the empty state so that Virtualizer + // won't try to render its body + let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'); + let rows = isEmptyOrLoading ? 0 : Math.ceil(collection.size / numColumns); + let iterator = collection[Symbol.iterator](); let y = rows > 0 ? minSpace.height : 0; let newLayoutInfos = new Map(); let skeleton: Node | null = null; @@ -136,6 +140,11 @@ export class GridLayout exte break; } + // We will add the loader after the skeletons so skip here + if (node.type === 'loader') { + continue; + } + if (node.type === 'skeleton') { skeleton = node; } @@ -177,6 +186,14 @@ export class GridLayout exte } } + // Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out. + let lastNode = collection.getItem(collection.getLastKey()!); + if (lastNode?.type === 'loader') { + let rect = new Rect(horizontalSpacing, y, itemWidth, 0); + let layoutInfo = new LayoutInfo('loader', lastNode.key, rect); + newLayoutInfos.set(lastNode.key, layoutInfo); + } + this.layoutInfos = newLayoutInfos; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); } @@ -192,7 +209,7 @@ export class GridLayout exte getVisibleLayoutInfos(rect: Rect): LayoutInfo[] { let layoutInfos: LayoutInfo[] = []; for (let layoutInfo of this.layoutInfos.values()) { - if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key)) { + if (layoutInfo.rect.intersects(rect) || this.virtualizer!.isPersistedKey(layoutInfo.key) || layoutInfo.type === 'loader') { layoutInfos.push(layoutInfo); } } diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..4937e77607a 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 { @@ -268,9 +268,22 @@ 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; } } @@ -301,6 +314,7 @@ export class ListLayout exte let layoutNode = this.buildNode(node, x, y); layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; this.layoutNodes.set(node.key, layoutNode); return layoutNode; } @@ -315,6 +329,8 @@ export class ListLayout exte return this.buildSectionHeader(node, x, y); case 'loader': return this.buildLoader(node, x, y); + case 'separator': + return this.buildItem(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } @@ -324,7 +340,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 4c820b5b1e9..55a3f182c42 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -11,7 +11,7 @@ */ import {DropTarget, ItemDropTarget, Key} from '@react-types/shared'; -import {getChildNodes} from '@react-stately/collections'; +import {getChildNodes, getLastItem} from '@react-stately/collections'; import {GridNode} from '@react-types/grid'; import {InvalidationContext, LayoutInfo, Point, Rect, Size} from '@react-stately/virtualizer'; import {LayoutNode, ListLayout, ListLayoutOptions} from './ListLayout'; @@ -251,7 +251,8 @@ export class TableLayout exten let width = 0; let children: LayoutNode[] = []; let rowHeight = this.getEstimatedRowHeight() + this.gap; - for (let node of getChildNodes(collection.body, collection)) { + let childNodes = getChildNodes(collection.body, collection); + for (let node of childNodes) { // Skip rows before the valid rectangle unless they are already cached. if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { y += rowHeight; @@ -267,13 +268,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; @@ -442,6 +462,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,25 +63,28 @@ export function useSelectState(props: SelectStateOptions): }); let [isFocused, setFocused] = useState(false); + let isEmpty = useMemo(() => listState.collection.size === 0 || (listState.collection.size === 1 && listState.collection.getItem(listState.collection.getFirstKey()!)?.type === 'loader'), [listState.collection]); + let open = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.open(); + } + }); + + let toggle = useEffectEvent((focusStrategy: FocusStrategy | null = null) => { + if (!isEmpty) { + setFocusStrategy(focusStrategy); + triggerState.toggle(); + } + }); return { ...validationState, ...listState, ...triggerState, focusStrategy, - open(focusStrategy: FocusStrategy | null = null) { - // Don't open if the collection is empty. - if (listState.collection.size !== 0) { - setFocusStrategy(focusStrategy); - triggerState.open(); - } - }, - toggle(focusStrategy: FocusStrategy | null = null) { - if (listState.collection.size !== 0) { - setFocusStrategy(focusStrategy); - triggerState.toggle(); - } - }, + open, + toggle, isFocused, setFocused }; diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index c627658a0bd..68ebaef6be4 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -311,7 +311,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..c29477c6730 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -474,3 +474,9 @@ input { [aria-autocomplete][data-focus-visible]{ outline: 3px solid blue; } + +.spinner { + position: absolute; + top: 50%; + left: 50%; +} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index a3a0fc78ea1..9c87edfaa4b 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,7 +213,8 @@ 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 = (
@@ -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 ( + <> + {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && renderProps.children && ( +
+
+ {renderProps.children} +
+
+ )} + + ); +}); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 246c9d9fc19..919c748e189 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}: ); } + // TODO: Think about if completely empty state. Do we leave it up to the user to setup the two states for empty and empty + loading? + // Do we add a data attibute/prop/renderprop to ListBox for isLoading return (
({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 +466,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 ( + <> + {/* TODO: Alway render the sentinel. Might need to style it as visually hidden? */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && renderProps.children && ( +
+ {renderProps.children} +
+ )} + + ); +}); diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index bdf3c932b24..f111c69d0f1 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} - + {/* TODO weird structure? Renders a extra row but we hide via inert so maybe ok? */} + {/* @ts-ignore - compatibility with React < 19 */} + + + {isLoading && renderProps.children && ( + + + {renderProps.children} + + + )} ); }); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index affe32c2442..cc729defa16 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -102,7 +102,9 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat onScrollEnd: state.endScrolling }, scrollRef!); - if (state.contentSize.area === 0) { + // TODO: wull have to update this when multi section loading is implemented, will need to check if all items in a collection are loaders instead perhaps + let hasLoadingSentinel = collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader'; + if (state.contentSize.area === 0 && !hasLoadingSentinel) { return null; } 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 553f46be57e..3bbeaddfd0c 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, {Suspense, useMemo, useRef, useState} from 'react'; +import {LoadingSpinner, MyMenuItem} from './utils'; +import React, {Suspense, useState} from 'react'; import styles from '../example/index.css'; -import {UNSTABLE_TableLoadingIndicator} from '../src/Table'; +import {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,8 @@ function VirtualizedTableWithEmptyState(args) { Baz renderEmptyLoader({isLoading: !args.showRows && args.isLoading})} rows={!args.showRows ? [] : rows}> {(item) => ( @@ -876,7 +871,7 @@ export const VirtualizedTableWithEmptyStateStory = { name: 'Virtualized Table With Empty State' }; -const OnLoadMoreTableVirtualized = () => { +const OnLoadMoreTableVirtualized = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -884,7 +879,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 +889,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 +906,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 +925,13 @@ const OnLoadMoreTableVirtualized = () => { export const OnLoadMoreTableStoryVirtualized = { render: OnLoadMoreTableVirtualized, - name: 'Virtualized Table with async loading' + name: 'Virtualized Table with async loading', + args: { + delay: 50 + } }; -const OnLoadMoreTableVirtualizedResizeWrapper = () => { +const OnLoadMoreTableVirtualizedResizeWrapper = (args) => { let list = useAsyncList({ async load({signal, cursor}) { if (cursor) { @@ -948,7 +939,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 +949,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 +967,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 +987,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..a093a040e5b 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,218 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); }); + + describe('async loading', () => { + let items = [ + {name: 'Foo'}, + {name: 'Bar'}, + {name: 'Baz'} + ]; + let renderEmptyState = () => { + return ( +
empty state
+ ); + }; + let AsyncGridList = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + ); + }; + + let onLoadMore = jest.fn(); + let observe = jest.fn(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the loading element when loading', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(4); + let loaderRow = rows[3]; + expect(loaderRow).toHaveTextContent('Loading...'); + + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should render the sentinel but not the loading indicator when not loading', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(3); + expect(tree.queryByText('Loading...')).toBeFalsy(); + expect(tree.getByTestId('loadMoreSentinel')).toBeInTheDocument(); + }); + + it('should properly render the renderEmptyState if gridlist is empty', 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).toHaveBeenCalledTimes(2); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(0); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(1); + + tree.rerender(); + expect(observe).toHaveBeenCalledTimes(3); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(onLoadMore).toHaveBeenCalledTimes(1); + + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); + expect(onLoadMore).toHaveBeenCalledTimes(2); + }); + + describe('virtualized', () => { + let items = []; + for (let i = 0; i < 50; i++) { + items.push({name: 'Foo' + i}); + } + let clientWidth, clientHeight; + + beforeAll(() => { + clientWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + clientHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + }); + + afterAll(function () { + clientWidth.mockReset(); + clientHeight.mockReset(); + }); + + let VirtualizedAsyncGridList = (props) => { + let {items, isLoading, onLoadMore, ...listBoxProps} = props; + return ( + + renderEmptyState()}> + + {(item) => ( + {item.name} + )} + + + Loading... + + + + ); + }; + + it('should always render the sentinel even when virtualized', async () => { + let tree = render(); + + let gridListTester = testUtilUser.createTester('GridList', {root: tree.getByRole('grid')}); + let rows = gridListTester.rows; + expect(rows).toHaveLength(8); + let loaderRow = rows[7]; + expect(loaderRow).toHaveTextContent('Loading...'); + expect(loaderRow).toHaveAttribute('aria-rowindex', '51'); + let loaderParentStyles = loaderRow.parentElement.style; + + // 50 items * 25px = 1250 + expect(loaderParentStyles.top).toBe('1250px'); + expect(loaderParentStyles.height).toBe('30px'); + + let sentinel = within(loaderRow.parentElement).getByTestId('loadMoreSentinel'); + expect(sentinel.parentElement).toHaveAttribute('inert'); + }); + + it('should not reserve room for the loader if isLoading is false', 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'); + + // Setting isLoading will render the loader even if the list is empty. + tree.rerender(); + rows = gridListTester.rows; + expect(rows).toHaveLength(2); + emptyStateRow = rows[1]; + expect(emptyStateRow).toHaveTextContent('empty state'); + + let loadingRow = rows[0]; + expect(loadingRow).toHaveTextContent('Loading...'); + + sentinel = within(gridListTester.gridlist).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..9bdd3e3840b 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} - - )} + + + {(item) => ( + + {item.foo} + {item.bar} + + )} + + +
spinner
+
@@ -1846,6 +1866,10 @@ describe('Table', () => { }); it('should fire onLoadMore when scrolling near the bottom', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1853,6 +1877,9 @@ describe('Table', () => { let scrollView = tree.getByTestId('scrollRegion'); expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = tree.getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(sentinel.nodeName).toBe('TD'); scrollView.scrollTop = 50; fireEvent.scroll(scrollView); @@ -1862,12 +1889,14 @@ describe('Table', () => { scrollView.scrollTop = 76; fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); it('doesn\'t call onLoadMore if it is already loading items', function () { + let observer = setupIntersectionObserverMock(); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1885,16 +1914,19 @@ describe('Table', () => { tree.rerender(); fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () { + let observer = setupIntersectionObserverMock(); jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); let tree = render(); tree.rerender(); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); expect(onLoadMore).toHaveBeenCalledTimes(1); }); @@ -1944,7 +1976,8 @@ describe('Table', () => { expect(onLoadMore).toHaveBeenCalledTimes(1); }); - it('allows the user to customize the scrollOffset required to trigger onLoadMore', function () { + // TODO: decide if we want to allow customization for this (I assume we will) + it.skip('allows the user to customize the scrollOffset required to trigger onLoadMore', function () { jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 100); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 25); @@ -1961,28 +1994,32 @@ describe('Table', () => { }); it('works with virtualizer', function () { + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({ + observe + }); let items = []; for (let i = 0; i < 6; i++) { items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); } function VirtualizedTableLoad() { - let scrollRef = useRef(null); - useLoadMore({onLoadMore}, scrollRef); - return ( - +
Foo Bar - - {item => ( - - {item.foo} - {item.bar} - - )} + + + {item => ( + + {item.foo} + {item.bar} + + )} + +
@@ -1999,10 +2036,13 @@ describe('Table', () => { return 25; }); - let {getByRole} = render(); + let {getByRole, getByTestId} = render(); let scrollView = getByRole('grid'); expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + expect(sentinel.nodeName).toBe('DIV'); scrollView.scrollTop = 50; fireEvent.scroll(scrollView); @@ -2012,6 +2052,7 @@ describe('Table', () => { scrollView.scrollTop = 76; fireEvent.scroll(scrollView); + act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);}); act(() => {jest.runAllTimers();}); expect(onLoadMore).toHaveBeenCalledTimes(1); @@ -2175,9 +2216,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2187,9 +2228,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2199,9 +2240,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); diff --git a/packages/react-stately/package.json b/packages/react-stately/package.json index 7caf830b77b..dd6bc73829c 100644 --- a/packages/react-stately/package.json +++ b/packages/react-stately/package.json @@ -52,7 +52,8 @@ "@react-types/shared": "^3.29.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "devDependencies": { "@babel/cli": "^7.24.1", 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 6dae49af9c3..ffdceeb7d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.2 + resolution: "@adobe/css-tools@npm:4.4.2" + checksum: 10c0/19433666ad18536b0ed05d4b53fbb3dd6ede266996796462023ec77a90b484890ad28a3e528cdf3ab8a65cb2fcdff5d8feb04db6bc6eed6ca307c40974239c94 + languageName: node + linkType: hard + "@adobe/react-spectrum-ui@npm:1.2.1": version: 1.2.1 resolution: "@adobe/react-spectrum-ui@npm:1.2.1" @@ -2979,6 +2986,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:^28.1.3": + version: 28.1.3 + resolution: "@jest/schemas@npm:28.1.3" + dependencies: + "@sinclair/typebox": "npm:^0.24.1" + checksum: 10c0/8c325918f3e1b83e687987b05c2e5143d171f372b091f891fe17835f06fadd864ddae3c7e221a704bdd7e2ea28c4b337124c02023d8affcbdd51eca2879162ac + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -7944,6 +7960,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/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" @@ -7956,6 +7973,7 @@ __metadata: "@react-types/shared": "npm:^3.29.0" "@react-types/table": "npm:^3.12.0" "@react-types/textfield": "npm:^3.12.1" + "@storybook/jest": "npm:^0.2.3" "@testing-library/dom": "npm:^10.1.0" "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" @@ -8555,6 +8573,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -8743,6 +8762,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-stately/select@workspace:packages/@react-stately/select" dependencies: + "@react-aria/utils": "npm:^3.28.2" "@react-stately/form": "npm:^3.1.3" "@react-stately/list": "npm:^3.12.1" "@react-stately/overlays": "npm:^3.6.15" @@ -8751,6 +8771,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -9428,6 +9449,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.24.1": + version: 0.24.51 + resolution: "@sinclair/typebox@npm:0.24.51" + checksum: 10c0/458131e83ca59ad3721f0abeef2aa5220aff2083767e1143d75c67c85d55ef7a212f48f394471ee6bdd2e860ba30f09a489cdd2a28a2824d5b0d1014bdfb2552 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -10181,6 +10209,15 @@ __metadata: languageName: node linkType: hard +"@storybook/expect@npm:storybook-jest": + version: 28.1.3-5 + resolution: "@storybook/expect@npm:28.1.3-5" + dependencies: + "@types/jest": "npm:28.1.3" + checksum: 10c0/ea912b18e1353cdd3bbdf93667ffebca7f843fa28a01e647429bffa6cb074afd4401d13eb2ecbfc9714e100e128ec1fe2686bded52e9e378ce44774889563558 + languageName: node + linkType: hard + "@storybook/global@npm:^5.0.0": version: 5.0.0 resolution: "@storybook/global@npm:5.0.0" @@ -10188,6 +10225,18 @@ __metadata: languageName: node linkType: hard +"@storybook/jest@npm:^0.2.3": + version: 0.2.3 + resolution: "@storybook/jest@npm:0.2.3" + dependencies: + "@storybook/expect": "npm:storybook-jest" + "@testing-library/jest-dom": "npm:^6.1.2" + "@types/jest": "npm:28.1.3" + jest-mock: "npm:^27.3.0" + checksum: 10c0/a2c367649ae53d9385b16f49bd73d5a928a2c3b9e64c2efcc1bbfc081b3b75972293bbe0e1828b67c94f0c2ed96341e0fae0ad5e30484a0ed4715724bbbf2c76 + languageName: node + linkType: hard + "@storybook/manager-api@npm:7.6.20, @storybook/manager-api@npm:^7.0.0, @storybook/manager-api@npm:^7.6.19": version: 7.6.20 resolution: "@storybook/manager-api@npm:7.6.20" @@ -10916,6 +10965,21 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.1.2": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10c0/5566b6c0b7b0709bc244aec3aa3dc9e5f4663e8fb2b99d8cd456fc07279e59db6076cbf798f9d3099a98fca7ef4cd50e4e1f4c4dec5a60a8fad8d24a638a5bf6 + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.0": version: 16.2.0 resolution: "@testing-library/react@npm:16.2.0" @@ -11329,6 +11393,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:28.1.3": + version: 28.1.3 + resolution: "@types/jest@npm:28.1.3" + dependencies: + jest-matcher-utils: "npm:^28.0.0" + pretty-format: "npm:^28.0.0" + checksum: 10c0/d295db8680b5c230698345d6caae621ea9fa8720309027e2306fabfd8769679b4bd7474b4f6e03788905c934eff62105bc0a3e3f1e174feee51b4551d49ac42a + languageName: node + linkType: hard + "@types/jscodeshift@npm:^0.11.11": version: 0.11.11 resolution: "@types/jscodeshift@npm:0.11.11" @@ -16597,6 +16671,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^28.1.1": + version: 28.1.1 + resolution: "diff-sequences@npm:28.1.1" + checksum: 10c0/26f29fa3f6b8c9040c3c6f6dab85413d90a09c8e6cb17b318bbcf64f225d7dcb1fb64392f3a9919a90888b434c4f6c8a4cc4f807aad02bbabae912c5d13c31f7 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -16694,6 +16775,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" @@ -22659,6 +22747,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^28.1.3": + version: 28.1.3 + resolution: "jest-diff@npm:28.1.3" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^28.1.1" + jest-get-type: "npm:^28.0.2" + pretty-format: "npm:^28.1.3" + checksum: 10c0/17a101ceb7e8f25c3ef64edda15cb1a259c2835395637099f3cc44f578fbd94ced7a13d11c0cbe8c5c1c3959a08544f0a913bec25a305b6dfc9847ce488e7198 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -22735,6 +22835,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^28.0.2": + version: 28.0.2 + resolution: "jest-get-type@npm:28.0.2" + checksum: 10c0/f64a40cfa10d79a56b383919033d35c8c4daee6145a1df31ec5ef2283fa7e8adbd443c6fcb4cfd0f60bbbd89f046c2323952f086b06e875cbbbc1a7d543a6e5e + languageName: node + linkType: hard + "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -22799,6 +22906,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^28.0.0": + version: 28.1.3 + resolution: "jest-matcher-utils@npm:28.1.3" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^28.1.3" + jest-get-type: "npm:^28.0.2" + pretty-format: "npm:^28.1.3" + checksum: 10c0/026fbe664cfdaed5a5c9facfc86ccc9bed3718a7d1fe061e355eb6158019a77f74e9b843bc99f9a467966cbebe60bde8b43439174cbf64997d4ad404f8f809d0 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -22837,7 +22956,7 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^27.0.6": +"jest-mock@npm:^27.0.6, jest-mock@npm:^27.3.0": version: 27.5.1 resolution: "jest-mock@npm:27.5.1" dependencies: @@ -28697,6 +28816,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^28.0.0, pretty-format@npm:^28.1.3": + version: 28.1.3 + resolution: "pretty-format@npm:28.1.3" + dependencies: + "@jest/schemas": "npm:^28.1.3" + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/596d8b459b6fdac7dcbd70d40169191e889939c17ffbcc73eebe2a9a6f82cdbb57faffe190274e0a507d9ecdf3affadf8a9b43442a625eecfbd2813b9319660f + languageName: node + linkType: hard + "pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -29563,6 +29694,7 @@ __metadata: "@storybook/addon-themes": "npm:^7.6.19" "@storybook/api": "npm:^7.6.19" "@storybook/components": "npm:^7.6.19" + "@storybook/jest": "npm:^0.2.3" "@storybook/manager-api": "npm:^7.6.19" "@storybook/preview": "npm:^7.6.19" "@storybook/preview-api": "npm:^7.6.19" @@ -29700,6 +29832,7 @@ __metadata: "@react-types/shared": "npm:^3.29.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft