Skip to content

Commit

Permalink
✨ TopAppBarMenu - add custom menu
Browse files Browse the repository at this point in the history
  • Loading branch information
Adrien Castagliola authored and clementdejoie committed Feb 29, 2024
1 parent a1342ad commit 1ded399
Show file tree
Hide file tree
Showing 9 changed files with 454 additions and 90 deletions.
85 changes: 78 additions & 7 deletions Storybook/components/TopAppBar/TopAppBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Headline, TopAppBar } from 'smartway-react-native-ui';
import type { Title } from 'src/components/topAppBar/TopAppBar';
import { TopAppBar, Title } from '../../../src/components/topAppBar/TopAppBar';
import { Headline } from '../../../src/components/typography/Headline';

const asString = { value: 'menu' };
const asButton = { value: 'menu', onPress: () => {} };
const asComponent = { value: <Headline size="h1">Headline H1</Headline> };
const asButton = {
value: 'menu',
onPress: () => {},
};
const asComponent = { value: <Headline size='h1'>Headline H1</Headline> };

type ComponentProps = React.ComponentProps<typeof TopAppBar> & {
withBackButton?: boolean;
Expand All @@ -28,12 +31,15 @@ export default {
control: { type: 'radio' },
options: ['small', 'medium', 'large', 'center-aligned'],
},
withTitleAs: { control: { type: 'radio' }, options: ['string', 'button', 'component'] },
withTitleAs: {
control: { type: 'radio' },
options: ['string', 'button', 'component'],
},
withBackButton: { type: 'boolean' },
onBack: { action: 'onBack' },
onPressIcon: { action: 'onPressIcon' },
onMenuItemPress: { action: 'onMenuItemPress' },
},

decorators: [
(Story) => {
const styles = StyleSheet.create({
Expand All @@ -60,9 +66,74 @@ export const Default: Story = {
size={args.size}
onBack={args.withBackButton ? args.onBack : undefined}
title={titleComponent}
icon={{ name: 'dots-vertical', onPress: args.onPressIcon }}
/>
);
},
};
export const WithMenuAction = (args) => {
let titleComponent: Title = asString;
if (args.withTitleAs === 'button') titleComponent = asButton;
if (args.withTitleAs === 'component') titleComponent = asComponent;

return (
<TopAppBar
size={args.size}
onBack={args.withBackButton ? args.onBack : undefined}
title={titleComponent}
action={
<TopAppBar.Menu>
<TopAppBar.MenuItem
iconName='notifications-off'
title='Ne plus surveiller'
onPress={() =>
args.onMenuItemPress('Ne plus surveiller')
}
/>
</TopAppBar.Menu>
}
/>
);
};
export const WithCloseAction = (args) => {
let titleComponent: Title = asString;
if (args.withTitleAs === 'button') titleComponent = asButton;
if (args.withTitleAs === 'component') titleComponent = asComponent;

return (
<TopAppBar
size={args.size}
onBack={args.withBackButton ? args.onBack : undefined}
title={titleComponent}
action={
<TopAppBar.Action
icon='close'
accessibilityLabel='Close'
onPress={args.onPressIcon}
/>
}
/>
);
};

export const WithPrinterSettingsAction = (args) => {
let titleComponent: Title = asString;
if (args.withTitleAs === 'button') titleComponent = asButton;
if (args.withTitleAs === 'component') titleComponent = asComponent;

return (
<TopAppBar
size={args.size}
onBack={args.withBackButton ? args.onBack : undefined}
title={titleComponent}
action={
<TopAppBar.Action
icon='cog'
accessibilityLabel='Settings'
onPress={args.onPressIcon}
/>
}
/>
);
};

Default.parameters = { noSafeArea: false };
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const jestConfig: JestConfigWithTsJest = {
testMatch: ['**/?(*.)test.(ts|tsx)'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
transformIgnorePatterns: [
'node_modules/(?!(@react-native|react-native|react-native-drop-shadow|@gorhom/bottom-sheet|react-native-reanimated)/)',
'node_modules/(?!(@react-native|react-native|react-native-drop-shadow|@gorhom/bottom-sheet|react-native-reanimated|react-native-paper)/)',
],
moduleDirectories: ['node_modules', 'src'],
setupFilesAfterEnv: ['./jest.setup.ts'],
Expand Down
133 changes: 133 additions & 0 deletions src/__tests__/components/TopAppBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React from 'react';
import { TopAppBar } from '../../components/topAppBar/TopAppBar';
import {
cleanUpFakeTimer,
render,
screen,
setupFakeTimer,
userEvent,
waitForElementToBeRemoved,
} from '../../shared/testUtils';
import { Text } from 'react-native';
import { IconName } from '../../components/icons/IconProps';
import TopAppBarMenuItem from '../../components/topAppBar/Menu/TopAppBarMenuItem';

const topBarTitle = 'Menu';

describe('TopAppBar mounting with a simple title', () => {
it('displays a title', () => {
const title = {
value: topBarTitle,
};

render(<TopAppBar title={title} />);

expect(screen.getByText(topBarTitle)).toBeOnTheScreen();
});

it.todo("testing menu title 'as string' on press");
});

describe('TopAppBar mounting with a title by passing a custom component', () => {
it('displays a title', () => {
const title = {
value: <Text>{topBarTitle}</Text>,
};

render(<TopAppBar title={title} />);

expect(screen.getByText(topBarTitle)).toBeOnTheScreen();
});

it.todo("testing menu title 'as component' on press");
});

describe('TopAppBar mounting with a go back button', () => {
beforeEach(() => setupFakeTimer());
afterEach(() => cleanUpFakeTimer());
it('triggers `goBack` event when user press the go back button', async () => {
const user = userEvent.setup();

const mockOnGoBack = jest.fn();

const title = {
value: topBarTitle,
};

render(<TopAppBar title={title} onBack={mockOnGoBack} />);

expect(mockOnGoBack).not.toHaveBeenCalled();

await user.press(screen.getByLabelText(/back/i));

expect(mockOnGoBack).toHaveBeenCalledTimes(1);
});
});

describe('TopAppBar mounting with a menu', () => {
const menuIconName = 'notifications-off' as const satisfies IconName;
const menuTitle = 'Ne plus surveiller';
const title = {
value: topBarTitle,
};
let mockOnMenuItemPress: jest.Mock;

beforeEach(() => {
setupFakeTimer();
mockOnMenuItemPress = jest.fn();

render(
<TopAppBar
title={title}
action={
<TopAppBar.Menu>
<TopAppBarMenuItem
iconName={menuIconName}
title={menuTitle}
onPress={mockOnMenuItemPress}
/>
</TopAppBar.Menu>
}
/>,
);
});
afterEach(() => cleanUpFakeTimer());

it('displays action menu button', () => {
expect(screen.getByLabelText(/menu/i)).toBeOnTheScreen();
});

it('displays menu items when user press the action menu button', async () => {
const user = userEvent.setup();

expect(screen.queryByText(menuTitle)).not.toBeOnTheScreen();

await user.press(screen.getByLabelText(/menu/i));

expect(screen.getByText(menuTitle)).toBeOnTheScreen();
});

it('retrieves menu item data when user press a menu item', async () => {
const user = userEvent.setup();

await user.press(screen.getByLabelText(/menu/i));

expect(mockOnMenuItemPress).not.toHaveBeenCalled();

await user.press(screen.getByText(menuTitle));

expect(mockOnMenuItemPress).toHaveBeenCalledTimes(1);
});

it('hides menu when user press a menu item', async () => {
const user = userEvent.setup();

await user.press(screen.getByLabelText(/menu/i));

expect(screen.getByText(menuTitle)).toBeOnTheScreen();

await user.press(screen.getByText(menuTitle));

await waitForElementToBeRemoved(() => screen.queryByText(menuTitle));
});
});
75 changes: 75 additions & 0 deletions src/components/topAppBar/Menu/TopAppBarMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { ReactNode } from 'react';
import { StyleSheet, View } from 'react-native';
import { Modal, Portal } from 'react-native-paper';
import { useTheme } from '../../../styles/themes';
import TopAppBarAction from '../TopAppBarAction';

type TopAppBarMenuContextValue =
| { isOpen: boolean; setIsOpen: (isOpen: boolean) => void }
| undefined;

const TopAppBarMenuContext =
React.createContext<TopAppBarMenuContextValue>(undefined);

type TopAppBarMenuProps = {
children: ReactNode;
};

const TopAppBarMenu = ({ children }: TopAppBarMenuProps) => {
const styles = useStyles();

const [isOpen, setIsOpen] = React.useState(false);

const value = { isOpen, setIsOpen };

return (
<>
<Portal>
<Modal
visible={isOpen}
onDismiss={() => setIsOpen(false)}
style={styles.modal}
contentContainerStyle={styles.modalContent}
>
<TopAppBarMenuContext.Provider value={value}>
<View style={styles.menu}>{children}</View>
</TopAppBarMenuContext.Provider>
</Modal>
</Portal>
<TopAppBarAction
accessibilityLabel='Menu'
icon='dots-vertical'
onPress={() => setIsOpen(true)}
/>
</>
);
};

function useTopAppBarMenu() {
const context = React.useContext(TopAppBarMenuContext);
if (context === undefined) {
throw new Error('useTopAppBarMenu must be used within a TopAppBarMenu');
}
return context;
}

function useStyles() {
const theme = useTheme();
return StyleSheet.create({
modal: {
alignItems: 'flex-end',
justifyContent: 'flex-start',
},
modalContent: {
marginTop: 84,
marginRight: theme.sw.spacing.xs,
},
menu: {
backgroundColor: theme.sw.colors.neutral['50'],
borderRadius: 18,
width: 248,
},
});
}

export { TopAppBarMenu, useTopAppBarMenu };
Loading

0 comments on commit 1ded399

Please sign in to comment.