diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index cb8a50dcbbd..b7ba4ece592 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,11 @@ 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. Determines whether or not the progress circle should + * be shown, and whether a "no results" message is displayed when there are no items. + */ + loadingState?: LoadingState; } export const MenuContext = @@ -413,6 +429,50 @@ let wrappingDiv = style({ size: 'full' }); +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 + } + } +}); + +const emptyStateText = style({ + gridColumnStart: '1', + gridColumnEnd: '-1', + 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' +}); + /** * Menus display a list of actions or options that a user can choose. */ @@ -428,10 +488,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 +555,27 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu - - {children} + + loadingState === 'loading' ? ( +
+ +
+ ) : ( + + {stringFormatter.format('combobox.noResults')} + + ) + }> + {renderer}
diff --git a/packages/@react-spectrum/s2/stories/Menu.stories.tsx b/packages/@react-spectrum/s2/stories/Menu.stories.tsx index 73e930bb138..2dddea902fe 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,68 @@ export const ContextMenu: Story = { ) }; +interface Character { + name: string; +} + +const AsyncMenuRender = (args: MenuProps & {delay: number}): ReactElement => { + 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/', {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: 50 + }, + name: 'Async loading menu' +}; + +export const EmptyState: Story = { + render: args => ( + + + + {() => Never rendered} + + + ) +}; + export const HoldAffordance: Story = { render: args => (
{ + 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); + + act(() => { + observer.instance.triggerCallback([{isIntersecting: true}]); + }); + act(() => { + jest.runAllTimers(); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); 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.