diff --git a/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx b/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx index dcfdd57c9b4..99fd608e80e 100644 --- a/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx @@ -18,9 +18,11 @@ import { PublishAndExport, UnavailableMenuItem } from '../stories/Menu.stories'; +import {Button} from '../src/Button'; import {expect} from '@storybook/jest'; -import {Menu} from '../src/Menu'; +import {Menu, MenuItem, MenuTrigger} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; +import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import {userEvent, within} from 'storybook/test'; const meta: Meta> = { @@ -31,7 +33,8 @@ const meta: Meta> = { backgrounds: ['base'], locales: ['en-US'], disableAnimations: true - } + }, + chromatic: {ignoreSelectors: ['[role="progressbar"]']} }, tags: ['autodocs'], title: 'S2 Chromatic/Menu' @@ -84,3 +87,65 @@ export const WithUnavailableItem: Story = { expect(menus).toHaveLength(2); } }; + +export const WithEmptyState: Story = { + render: () => ( + + + + {() => Never rendered} + + + ), + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let menu = await within(body).findByRole('menu'); + await within(menu).findByText('No results'); + } +}; + +export const WithInitialLoading: Story = { + render: () => ( + + + + {() => Never rendered} + + + ), + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let menu = await within(body).findByRole('menu'); + await within(menu).findByRole('progressbar', {hidden: true}); + } +}; + +export const WithLoadMore: Story = { + render: () => ( + + + + Cut + Copy + Paste + + + ), + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + let menu = await within(body).findByRole('menu'); + await within(menu).findByRole('progressbar', {hidden: true}); + } +}; diff --git a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx index 70d2a9fd169..2745a2b860e 100644 --- a/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Picker.stories.tsx @@ -105,6 +105,23 @@ export const ContextualHelp: Story = { } }; +export const WithLoadMore: Story = { + render: () => ( + + Chocolate + Mint + Strawberry + + ), + 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', {hidden: true}); + } +}; + export const EmptyAndLoading: Story = { render: () => ( diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 26f797774b0..0e3f8978636 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -39,7 +39,15 @@ import {BaseCollection, CollectionNode} from 'react-aria/private/collections/Bas import {baseColor, centerPadding, focusRing, space, style} from '../style' with {type: 'macro'}; import {Button, ButtonRenderProps} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; -import {checkmark, description, icon, iconCenterWrapper, label, sectionHeading} from './Menu'; +import { + checkmark, + description, + icon, + iconCenterWrapper, + label, + loadingWrapperStyles, + sectionHeading +} from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {Collection} from 'react-aria/Collection'; @@ -210,15 +218,6 @@ const iconStyles = style({ } }); -const loadingWrapperStyles = style({ - gridColumnStart: '1', - gridColumnEnd: '-1', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - marginY: 8 -}); - const progressCircleStyles = style({ size: '1lh', marginStart: { diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index cb8a50dcbbd..b75a8128eef 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -15,6 +15,7 @@ import { Menu as AriaMenu, MenuItem as AriaMenuItem, MenuItemProps as AriaMenuItemProps, + MenuLoadMoreItem as AriaMenuLoadMoreItem, MenuProps as AriaMenuProps, MenuSection as AriaMenuSection, MenuSectionProps as AriaMenuSectionProps, @@ -24,6 +25,14 @@ import { SubmenuTriggerProps as AriaSubmenuTriggerProps, MenuItemRenderProps } from 'react-aria-components/Menu'; +import { + AsyncLoadable, + DOMRef, + DOMRefValue, + GlobalDOMAttributes, + LoadingState, + PressEvent +} from '@react-types/shared'; import { baseColor, centerPadding, @@ -37,6 +46,7 @@ import {box, iconStyles} from './Checkbox'; import {centerBaseline} from './CenterBaseline'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronRightIcon from '../ui-icons/Chevron'; +import {Collection} from 'react-aria/Collection'; import {ContextValue, DEFAULT_SLOT, Provider, useSlottedContext} from 'react-aria-components/slots'; import { control, @@ -56,7 +66,6 @@ import { useState } from 'react'; import {divider} from './Divider'; -import {DOMRef, DOMRefValue, GlobalDOMAttributes, PressEvent} from '@react-types/shared'; import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content'; @@ -70,6 +79,7 @@ import {mergeStyles} from '../style/runtime'; import {Placement} from 'react-aria/useOverlayPosition'; import {PressResponder} from 'react-aria/private/interactions/PressResponder'; import {pressScale} from './pressScale'; +import {ProgressCircle} from './ProgressCircle'; import {Separator, SeparatorProps} from 'react-aria-components/Separator'; import {ToggleButtonContext} from './ToggleButton'; import {useGlobalListeners} from 'react-aria/private/utils/useGlobalListeners'; @@ -107,6 +117,7 @@ export interface MenuProps AriaMenuProps, 'children' | 'style' | 'className' | 'render' | 'renderEmptyState' | keyof GlobalDOMAttributes >, + Pick, StyleProps { /** * The size of the Menu. @@ -120,6 +131,10 @@ export interface MenuProps children: ReactNode | ((item: T) => ReactNode); /** Hides the default link out icons on menu items that open links in a new tab. */ hideLinkOutIcon?: boolean; + /** + * The current loading state of the Menu. + */ + loadingState?: LoadingState; } export const MenuContext = @@ -413,6 +428,41 @@ let wrappingDiv = style({ size: 'full' }); +export const loadingWrapperStyles = style({ + gridColumnStart: '1', + gridColumnEnd: '-1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginY: 8 +}); + +export const progressCircleStyles = style({ + size: '1lh' +}); + +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', + paddingX: 'edge-to-text' +}); + /** * Menus display a list of actions or options that a user can choose. */ @@ -428,10 +478,48 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu + + + ); + + let renderer; + if (typeof children === 'function' && items) { + renderer = ( + <> + + {children} + + {menuLoadingCircle} + + ); + } else { + renderer = ( + <> + {children} + {menuLoadingCircle} + + ); + } let isPopover = (ctx || isSubmenu) && !inPopover; let content = ( @@ -457,8 +545,26 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu - - {children} + + loadingState === 'loading' ? ( +
+ +
+ ) : ( + + {stringFormatter.format('combobox.noResults')} + + ) + }> + {renderer}
diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index a4feceb71f9..48c00d2bfe9 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -50,6 +50,8 @@ import { icon, iconCenterWrapper, label, + loadingWrapperStyles, + progressCircleStyles, sectionHeading } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; @@ -291,26 +293,6 @@ const avatar = style({ marginEnd: 'text-to-visual' }); -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 - } - } -}); - let InternalPickerContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); let InsideSelectValueContext = createContext(false); @@ -527,14 +509,14 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick }); function PickerProgressCircle(props) { - let {id, size, 'aria-label': ariaLabel} = props; + let {id, 'aria-label': ariaLabel} = props; return ( ); } diff --git a/packages/@react-spectrum/s2/stories/Menu.stories.tsx b/packages/@react-spectrum/s2/stories/Menu.stories.tsx index 73e930bb138..222905c1467 100644 --- a/packages/@react-spectrum/s2/stories/Menu.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Menu.stories.tsx @@ -54,6 +54,7 @@ import StampClone from '../s2wf-icons/S2_Icon_StampClone_20_N.svg'; import TextIcon from '../s2wf-icons/S2_Icon_Text_20_N.svg'; import {ToggleButton} from '../src/ToggleButton'; import Underline from '../s2wf-icons/S2_Icon_TextUnderline_20_N.svg'; +import {useAsyncList} from 'react-stately/useAsyncList'; const events = ['onAction', 'onClose', 'onOpenChange', 'onScroll', 'onSelectionChange']; @@ -448,6 +449,63 @@ export const ContextMenu: Story = { ) }; +interface Character { + name: string; +} + +const AsyncMenuRender = ( + args: MenuProps & {delay: number; isEmpty: boolean} +): ReactElement => { + let {delay, isEmpty, ...menuTriggerArgs} = 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, delay)); + let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/', {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + + + {(item: Character) => {item.name}} + + + ); +}; + +export type AsyncMenuStoryType = typeof AsyncMenuRender; +export const AsyncMenuStory: StoryObj = { + render: AsyncMenuRender, + args: { + delay: 2000, + isEmpty: false + }, + argTypes: { + delay: {control: 'number'}, + isEmpty: {control: 'boolean'} + }, + name: 'Async loading menu' +}; + export const HoldAffordance: Story = { render: args => (
{ let sentinel = tree.getByTestId('loadMoreSentinel'); expect(observe).toHaveBeenLastCalledWith(sentinel); - act(() => { - observer.instance.triggerCallback([{isIntersecting: true}]); + await act(async () => { + await observer.instance.triggerCallback([{isIntersecting: true}]); }); act(() => { jest.runAllTimers(); @@ -129,8 +129,8 @@ describe('Combobox', () => { ); - act(() => { - observer.instance.triggerCallback([{isIntersecting: true}]); + await act(async () => { + await observer.instance.triggerCallback([{isIntersecting: true}]); }); act(() => { jest.runAllTimers(); diff --git a/packages/@react-spectrum/s2/test/Menu.test.tsx b/packages/@react-spectrum/s2/test/Menu.test.tsx index c2359cb0c99..bd469180048 100644 --- a/packages/@react-spectrum/s2/test/Menu.test.tsx +++ b/packages/@react-spectrum/s2/test/Menu.test.tsx @@ -15,7 +15,9 @@ import { installPointerEvent, pointerMap, render, - User + setupIntersectionObserverMock, + User, + within } from '@react-spectrum/test-utils-internal'; import {ActionButton, ActionButtonContext} from '../src/ActionButton'; import {AriaMenuTests} from '../../../react-aria-components/test/AriaMenu.test-util'; @@ -379,3 +381,85 @@ AriaMenuTests({ ) } }); + +describe('Async loading', () => { + let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime}); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should show a "No results" message when there are no items and it is not loading', async () => { + let {getByRole} = render( + + + + {(item: any) => {item.name}} + + + ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + await menuTester.open(); + expect(menuTester.getMenu()).toHaveTextContent('No results'); + }); + + it('should show a progress circle when loadingState is "loading"', async () => { + let {getByRole} = render( + + + + {(item: any) => {item.name}} + + + ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + await menuTester.open(); + expect( + within(menuTester.getMenu()!).getByRole('progressbar', {hidden: true}) + ).toBeInTheDocument(); + }); + + it('should call onLoadMore when intersection is detected while loadingState is "loadingMore"', async () => { + let onLoadMore = jest.fn(); + let observe = jest.fn(); + let observer = setupIntersectionObserverMock({observe}); + + let {getByRole, getByTestId} = render( + + + + Cut + Copy + Paste + + + ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + await menuTester.open(); + + expect(onLoadMore).toHaveBeenCalledTimes(0); + let sentinel = getByTestId('loadMoreSentinel'); + expect(observe).toHaveBeenLastCalledWith(sentinel); + + await act(async () => { + await observer.instance.triggerCallback([{isIntersecting: true}]); + }); + act(() => { + jest.runAllTimers(); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index 05d59184f1e..55858bf6dc7 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -80,8 +80,8 @@ describe('Picker', () => { let sentinel = tree.getByTestId('loadMoreSentinel'); expect(observe).toHaveBeenLastCalledWith(sentinel); - act(() => { - observer.instance.triggerCallback([{isIntersecting: true}]); + await act(async () => { + await observer.instance.triggerCallback([{isIntersecting: true}]); }); act(() => { jest.runAllTimers(); @@ -97,8 +97,8 @@ describe('Picker', () => { ); - act(() => { - observer.instance.triggerCallback([{isIntersecting: true}]); + await act(async () => { + await observer.instance.triggerCallback([{isIntersecting: true}]); }); act(() => { jest.runAllTimers(); diff --git a/packages/dev/s2-docs/pages/s2/Menu.mdx b/packages/dev/s2-docs/pages/s2/Menu.mdx index 0aaca4221e9..16c9f604963 100644 --- a/packages/dev/s2-docs/pages/s2/Menu.mdx +++ b/packages/dev/s2-docs/pages/s2/Menu.mdx @@ -283,6 +283,54 @@ function Example(props) { } ``` +### Asynchronous loading + +Use the `loadingState` and `onLoadMore` props to enable async loading and infinite scrolling. + +```tsx render +"use client"; +import {MenuTrigger, Menu, MenuItem} from '@react-spectrum/s2/Menu'; +import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {useAsyncList} from '@react-spectrum/s2/useAsyncList'; + +interface Character { + name: string +} + +function Example() { + let list = useAsyncList({ + async load({signal, cursor}) { + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + + let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/', {signal}); + let json = await res.json(); + + return { + items: json.results, + cursor: json.next + }; + } + }); + + return ( + + Select Character + + {/*- end highlight -*/} + {item => {item.name}} + + + ); +} +``` + ### Links Use the `href` prop on a `` to create a link. See the [getting started guide](getting-started) to learn how to integrate with your framework.