Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 121 additions & 4 deletions packages/@react-spectrum/s2/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -107,6 +117,7 @@ export interface MenuProps<T>
AriaMenuProps<T>,
'children' | 'style' | 'className' | 'render' | 'renderEmptyState' | keyof GlobalDOMAttributes
>,
Pick<AsyncLoadable, 'onLoadMore'>,
StyleProps {
/**
* The size of the Menu.
Expand All @@ -120,6 +131,11 @@ export interface MenuProps<T>
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 =
Expand Down Expand Up @@ -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.
*/
Expand All @@ -428,10 +488,48 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu<T
UNSAFE_style,
UNSAFE_className,
styles,
hideLinkOutIcon = false
hideLinkOutIcon = false,
items,
loadingState,
onLoadMore
} = props;
let ctx = useContext(InternalMenuTriggerContext);
let inPopover = useContext(InPopoverContext);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');

let menuLoadingCircle = (
<AriaMenuLoadMoreItem
isLoading={loadingState === 'loadingMore'}
onLoadMore={onLoadMore}
className={loadingWrapperStyles}>
<ProgressCircle
isIndeterminate
size="S"
styles={progressCircleStyles({size})}
// Same loading string as table
aria-label={stringFormatter.format('table.loadingMore')}
/>
</AriaMenuLoadMoreItem>
);

let renderer;
if (typeof children === 'function' && items) {
renderer = (
<>
<Collection items={items} dependencies={props.dependencies}>
{children}
</Collection>
{menuLoadingCircle}
</>
);
} else {
renderer = (
<>
{children}
{menuLoadingCircle}
</>
);
}

let isPopover = (ctx || isSubmenu) && !inPopover;
let content = (
Expand All @@ -457,8 +555,27 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu<T
],
[InPopoverContext, false]
]}>
<AriaMenu {...props} className={menu({size, isPopover}, isPopover ? null : styles)}>
{children}
<AriaMenu
{...props}
className={menu({size, isPopover}, isPopover ? null : styles)}
renderEmptyState={() =>
loadingState === 'loading' ? (
<div className={loadingWrapperStyles}>
<ProgressCircle
isIndeterminate
size="S"
styles={progressCircleStyles({size})}
// Same loading string as table
aria-label={stringFormatter.format('table.loading')}
/>
</div>
) : (
<span className={emptyStateText({size})}>
{stringFormatter.format('combobox.noResults')}
</span>
)
}>
{renderer}
</AriaMenu>
</Provider>
</InternalMenuContext.Provider>
Expand Down
63 changes: 63 additions & 0 deletions packages/@react-spectrum/s2/stories/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -448,6 +449,68 @@ export const ContextMenu: Story = {
)
};

interface Character {
name: string;
}

const AsyncMenuRender = (args: MenuProps<Character> & {delay: number}): ReactElement => {
let list = useAsyncList<Character>({
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 (
<MenuTrigger>
<Button aria-label="Select Character">
<NewIcon />
</Button>
<Menu
{...args}
aria-label="Star Wars Characters"
items={list.items}
loadingState={list.loadingState}
onLoadMore={list.loadMore}>
{(item: Character) => <MenuItem id={item.name}>{item.name}</MenuItem>}
</Menu>
</MenuTrigger>
);
};

export type AsyncMenuStoryType = typeof AsyncMenuRender;
export const AsyncMenuStory: StoryObj<AsyncMenuStoryType> = {
render: AsyncMenuRender,
args: {
delay: 50
},
name: 'Async loading menu'
};

export const EmptyState: Story = {
render: args => (
<MenuTrigger {...args}>
<Button aria-label="Actions">
<NewIcon />
</Button>
<Menu {...args} items={[]}>
{() => <MenuItem>Never rendered</MenuItem>}
</Menu>
</MenuTrigger>
)
};

export const HoldAffordance: Story = {
render: args => (
<div
Expand Down
86 changes: 85 additions & 1 deletion packages/@react-spectrum/s2/test/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<MenuTrigger>
<Button variant="primary">Menu Button</Button>
<Menu aria-label="Test" items={[]}>
{(item: any) => <MenuItem id={item.id}>{item.name}</MenuItem>}
</Menu>
</MenuTrigger>
);

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(
<MenuTrigger>
<Button variant="primary">Menu Button</Button>
<Menu aria-label="Test" items={[]} loadingState="loading">
{(item: any) => <MenuItem id={item.id}>{item.name}</MenuItem>}
</Menu>
</MenuTrigger>
);

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(
<MenuTrigger>
<Button variant="primary">Menu Button</Button>
<Menu aria-label="Test" loadingState="loadingMore" onLoadMore={onLoadMore}>
<MenuItem>Cut</MenuItem>
<MenuItem>Copy</MenuItem>
<MenuItem>Paste</MenuItem>
</Menu>
</MenuTrigger>
);

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);
});
});
Loading