Skip to content
Merged
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
11 changes: 7 additions & 4 deletions src/__stories__/menu-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,14 @@ export const Default: StoryComponent<MenuArgs> = ({
? `Description for option ${optionIndex + 1}`
: undefined
}
onPress={(item) => {
onPress={() => {
if (checkbox) {
setValues(item);
setValues(optionIndex);
} else {
alert({title: `Item ${item + 1}`, message: 'pressed'});
alert({
title: `Item ${optionIndex + 1}`,
message: 'pressed',
});
}
}}
{...(checkbox && {
Expand Down Expand Up @@ -179,7 +182,7 @@ export const InsideCard: StoryComponent = () => {
<MenuItem
key={optionIndex + 1}
label={`Option ${optionIndex + 1}`}
onPress={() => null}
onPress={() => {}}
/>
))}
</div>
Expand Down
107 changes: 106 additions & 1 deletion src/__tests__/menu-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,26 @@ import userEvent from '@testing-library/user-event';
import {render, screen, waitFor} from '@testing-library/react';
import {ThemeContextProvider, Touchable, Text3, Menu, MenuSection, MenuItem} from '..';
import {makeTheme} from './test-utils';
import {redirect as redirectSpy} from '../utils/browser';
import {MemoryRouter, useLocation, Link as ReactRouterLink} from 'react-router-dom';

import type {ThemeConfig} from '../theme';

const Link: ThemeConfig['Link'] = ({innerRef, ...props}) => <ReactRouterLink {...props} ref={innerRef} />;

jest.mock('../utils/browser', () => ({
...jest.requireActual('../utils/browser'),
redirect: jest.fn(),
}));

beforeEach(() => {
(redirectSpy as any).mockReset();
});

const options = ['Option 1', 'Option 2', 'Option 3'];

test('Menu closes after pressing an option', async () => {
const onPressSpy = jest.fn();
render(
<ThemeContextProvider theme={makeTheme()}>
<Menu
Expand All @@ -19,7 +35,7 @@ test('Menu closes after pressing an option', async () => {
<div ref={ref} className={className}>
<MenuSection>
{options.map((option) => (
<MenuItem key={option} label={option} onPress={() => {}} />
<MenuItem key={option} label={option} onPress={onPressSpy} />
))}
</MenuSection>
</div>
Expand All @@ -39,6 +55,8 @@ test('Menu closes after pressing an option', async () => {

await userEvent.click(screen.getByText('Option 1'));

expect(onPressSpy).toHaveBeenCalledTimes(1);

/** We have to wait until the CSS transition finishes when closing the menu */
await waitFor(() => {
expect(screen.getByText('menu is close')).toBeInTheDocument();
Expand All @@ -47,3 +65,90 @@ test('Menu closes after pressing an option', async () => {
expect(screen.queryByText('Option 3')).not.toBeInTheDocument();
});
});

test('Menu closes after clicking an href option', async () => {
render(
<ThemeContextProvider theme={makeTheme()}>
<Menu
renderTarget={({ref, onPress, isMenuOpen}) => (
<Touchable ref={ref} onPress={onPress}>
<Text3 regular>{isMenuOpen ? 'menu is open' : 'menu is close'}</Text3>
</Touchable>
)}
renderMenu={({ref, className}) => (
<div ref={ref} className={className}>
<MenuSection>
<MenuItem label="External link" href="https://example.com" />
</MenuSection>
</div>
)}
/>
</ThemeContextProvider>
);

await userEvent.click(screen.getByText('menu is close'));

expect(screen.getByText('menu is open')).toBeInTheDocument();

const menuItem = screen.getByRole('menuitem', {name: 'External link'});
expect(menuItem.tagName).toBe('A');
expect(menuItem).toHaveAttribute('href', 'https://example.com');

await userEvent.click(menuItem);

await waitFor(() => {
expect(redirectSpy).toHaveBeenCalledWith('https://example.com', false, false);
});

/** We have to wait until the CSS transition finishes when closing the menu */
await waitFor(() => {
expect(screen.getByText('menu is close')).toBeInTheDocument();
});
});

test('Menu closes after clicking a "to" option', async () => {
const to = '/interna';

const CurrentPath = () => <div>Current path: {useLocation().pathname}</div>;

render(
<ThemeContextProvider theme={makeTheme({Link})}>
<MemoryRouter>
<Menu
renderTarget={({ref, onPress, isMenuOpen}) => (
<Touchable ref={ref} onPress={onPress}>
<Text3 regular>{isMenuOpen ? 'menu is open' : 'menu is close'}</Text3>
</Touchable>
)}
renderMenu={({ref, className}) => (
<div ref={ref} className={className}>
<MenuSection>
<MenuItem label="Internal link" to={to} />
</MenuSection>
</div>
)}
/>
<CurrentPath />
</MemoryRouter>
</ThemeContextProvider>
);

await userEvent.click(screen.getByText('menu is close'));

expect(screen.getByText('menu is open')).toBeInTheDocument();

const menuItem = screen.getByRole('menuitem', {name: 'Internal link'});
expect(menuItem.tagName).toBe('A');
expect(menuItem).toHaveAttribute('href', to);

expect(screen.getByText('Current path: /')).toBeInTheDocument();

await userEvent.click(menuItem);

expect(screen.getByText(`Current path: ${to}`)).toBeInTheDocument();

/** We have to wait until the CSS transition finishes when closing the menu */
await waitFor(() => {
expect(screen.getByText('menu is close')).toBeInTheDocument();
});
});
106 changes: 82 additions & 24 deletions src/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,28 +57,53 @@ interface MenuItemBaseProps {
Icon?: (props: IconProps) => JSX.Element;
destructive?: boolean;
disabled?: boolean;
onPress: (item: number) => void;
Comment thread
MurilloLeoni marked this conversation as resolved.
dataAttributes?: DataAttributes;
}

interface MenuItemWithoutControlProps extends MenuItemBaseProps {
interface MenuItemOnPressProps extends MenuItemBaseProps {
onPress: (item: number) => void;
controlType?: 'checkbox';
checked?: boolean;
href?: undefined;
to?: undefined;
}

interface MenuItemHrefProps extends MenuItemBaseProps {
href: string;
newTab?: boolean;
loadOnTop?: boolean;
onNavigate?: () => void | Promise<void>;
onPress?: undefined;
to?: undefined;
controlType?: undefined;
checked?: undefined;
}

interface MenuItemWithControlProps extends MenuItemBaseProps {
controlType?: 'checkbox';
checked?: boolean;
interface MenuItemToProps extends MenuItemBaseProps {
to: string;
newTab?: boolean;
fullPageOnWebView?: boolean;
onNavigate?: () => void | Promise<void>;
onPress?: undefined;
href?: undefined;
controlType?: undefined;
checked?: undefined;
}

type MenuItemProps = ExclusifyUnion<MenuItemWithControlProps | MenuItemWithoutControlProps>;
type MenuItemProps = ExclusifyUnion<MenuItemOnPressProps | MenuItemHrefProps | MenuItemToProps>;

export const MenuItem = ({
label,
Icon,
destructive,
disabled,
onPress,
href,
to,
newTab,
loadOnTop,
fullPageOnWebView,
onNavigate,
controlType,
checked,
description,
Expand Down Expand Up @@ -108,6 +133,17 @@ export const MenuItem = ({
</div>
);

const renderItemContent = (labelId?: string) => (
<div className={styles.itemContent}>
{Icon && (
<div className={styles.iconContainer}>
<Icon size={24} color={contentColor} />
</div>
)}
{renderTextContent(labelId)}
</div>
);

const renderContent = () =>
controlType === 'checkbox' ? (
<Checkbox
Expand All @@ -116,7 +152,7 @@ export const MenuItem = ({
checked={checked}
onChange={() => {
if (isMenuOpen && itemIndex !== null) {
onPress(itemIndex);
onPress?.(itemIndex);
closeMenu();
}
}}
Expand All @@ -126,25 +162,54 @@ export const MenuItem = ({
render={({controlElement, labelId}) => (
<Box paddingX={8} paddingY={12}>
<Inline space="between" alignItems="center">
<div className={styles.itemContent}>
{Icon && (
<div className={styles.iconContainer}>
<Icon size={24} color={contentColor} />
</div>
)}
{renderTextContent(labelId)}
</div>
{renderItemContent(labelId)}
<Box paddingLeft={16}>{controlElement}</Box>
</Inline>
</Box>
)}
/>
) : href ? (
<Touchable
ref={itemRef}
href={href}
newTab={newTab}
loadOnTop={loadOnTop}
onNavigate={() => {
closeMenu();
onNavigate?.();
}}
disabled={disabled}
role="menuitem"
dataAttributes={menuItemDataAttributes}
>
<Box paddingX={8} paddingY={12}>
{renderItemContent()}
</Box>
Comment thread
MurilloLeoni marked this conversation as resolved.
</Touchable>
) : to ? (
<Touchable
ref={itemRef}
to={to}
newTab={newTab}
fullPageOnWebView={fullPageOnWebView}
onNavigate={() => {
closeMenu();
onNavigate?.();
}}
disabled={disabled}
role="menuitem"
dataAttributes={menuItemDataAttributes}
>
<Box paddingX={8} paddingY={12}>
{renderItemContent()}
</Box>
</Touchable>
) : (
<Touchable
ref={itemRef}
onPress={() => {
if (isMenuOpen && itemIndex !== null) {
onPress(itemIndex);
onPress?.(itemIndex);
closeMenu();
}
}}
Expand All @@ -153,14 +218,7 @@ export const MenuItem = ({
dataAttributes={menuItemDataAttributes}
>
<Box paddingX={8} paddingY={12}>
<div className={styles.itemContent}>
{Icon && (
<div className={styles.iconContainer}>
<Icon size={24} color={contentColor} />
</div>
)}
{renderTextContent()}
</div>
{renderItemContent()}
</Box>
</Touchable>
);
Expand Down
Loading