diff --git a/Storybook/components/TopAppBar/TopAppBar.stories.tsx b/Storybook/components/TopAppBar/TopAppBar.stories.tsx index c279da69..1772e822 100644 --- a/Storybook/components/TopAppBar/TopAppBar.stories.tsx +++ b/Storybook/components/TopAppBar/TopAppBar.stories.tsx @@ -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 H1 }; +const asButton = { + value: 'menu', + onPress: () => {}, +}; +const asComponent = { value: Headline H1 }; type ComponentProps = React.ComponentProps & { withBackButton?: boolean; @@ -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({ @@ -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 ( + + + args.onMenuItemPress('Ne plus surveiller') + } + /> + + } + /> + ); +}; +export const WithCloseAction = (args) => { + let titleComponent: Title = asString; + if (args.withTitleAs === 'button') titleComponent = asButton; + if (args.withTitleAs === 'component') titleComponent = asComponent; + + return ( + + } + /> + ); +}; + +export const WithPrinterSettingsAction = (args) => { + let titleComponent: Title = asString; + if (args.withTitleAs === 'button') titleComponent = asButton; + if (args.withTitleAs === 'component') titleComponent = asComponent; + + return ( + + } + /> + ); +}; + Default.parameters = { noSafeArea: false }; diff --git a/Storybook/package-lock.json b/Storybook/package-lock.json index 9e4eb3f4..71381112 100644 --- a/Storybook/package-lock.json +++ b/Storybook/package-lock.json @@ -56,7 +56,7 @@ "@storybook/testing-library": "^0.0.13", "@tsconfig/react-native": "^2.0.2", "@types/jest": "^29.2.1", - "@types/react": "^18.0.24", + "@types/react": "^18.2.0", "@types/react-native": "^0.70.6", "@types/react-test-renderer": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.37.0", @@ -17874,9 +17874,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", "devOptional": true, "dependencies": { "@types/prop-types": "*", diff --git a/Storybook/package.json b/Storybook/package.json index d2c9c432..5b26bb82 100644 --- a/Storybook/package.json +++ b/Storybook/package.json @@ -64,7 +64,7 @@ "@storybook/testing-library": "^0.0.13", "@tsconfig/react-native": "^2.0.2", "@types/jest": "^29.2.1", - "@types/react": "^18.0.24", + "@types/react": "^18.2.0", "@types/react-native": "^0.70.6", "@types/react-test-renderer": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.37.0", diff --git a/jest.config.ts b/jest.config.ts index 69d5daec..6303f3a8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -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'], diff --git a/package-lock.json b/package-lock.json index 21a40e95..c840a4f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "devDependencies": { "@testing-library/react-native": "^12.4.2", "@types/jest": "^29.5.5", - "@types/react": "^18.2.45", + "@types/react": "^18.2.0", "@types/react-native": "^0.73.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "eslint": "^8.56.0", @@ -4403,9 +4403,9 @@ "devOptional": true }, "node_modules/@types/react": { - "version": "18.2.45", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", - "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", "devOptional": true, "dependencies": { "@types/prop-types": "*", @@ -18127,9 +18127,9 @@ "devOptional": true }, "@types/react": { - "version": "18.2.45", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", - "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", "devOptional": true, "requires": { "@types/prop-types": "*", diff --git a/package.json b/package.json index 37cf415f..38e3cab4 100644 --- a/package.json +++ b/package.json @@ -47,11 +47,11 @@ "devDependencies": { "@testing-library/react-native": "^12.4.2", "@types/jest": "^29.5.5", - "@types/react": "^18.2.45", + "@types/react": "^18.2.0", "@types/react-native": "^0.73.0", "@typescript-eslint/eslint-plugin": "^6.21.0", - "eslint-config-prettier": "^9.1.0", "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "19.0.1", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.32.2", diff --git a/src/__tests__/components/TopAppBar.test.tsx b/src/__tests__/components/TopAppBar.test.tsx new file mode 100644 index 00000000..98966eee --- /dev/null +++ b/src/__tests__/components/TopAppBar.test.tsx @@ -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(); + + 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: {topBarTitle}, + }; + + render(); + + 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(); + + 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( + + + + } + />, + ); + }); + 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)); + }); +}); diff --git a/src/components/topAppBar/Menu/TopAppBarMenu.tsx b/src/components/topAppBar/Menu/TopAppBarMenu.tsx new file mode 100644 index 00000000..59a44774 --- /dev/null +++ b/src/components/topAppBar/Menu/TopAppBarMenu.tsx @@ -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(undefined); + +type TopAppBarMenuProps = { + children: ReactNode; +}; + +const TopAppBarMenu = ({ children }: TopAppBarMenuProps) => { + const styles = useStyles(); + + const [isOpen, setIsOpen] = React.useState(false); + + const value = { isOpen, setIsOpen }; + + return ( + <> + + setIsOpen(false)} + style={styles.modal} + contentContainerStyle={styles.modalContent} + > + + {children} + + + + 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 }; diff --git a/src/components/topAppBar/Menu/TopAppBarMenuItem.tsx b/src/components/topAppBar/Menu/TopAppBarMenuItem.tsx new file mode 100644 index 00000000..5f87c9b7 --- /dev/null +++ b/src/components/topAppBar/Menu/TopAppBarMenuItem.tsx @@ -0,0 +1,74 @@ +import React, { ComponentPropsWithoutRef } from 'react'; +import { Menu } from 'react-native-paper'; +import { Theme, useTheme } from '../../../styles/themes'; +import { GestureResponderEvent, StyleSheet } from 'react-native'; +import { Icon } from '../../icons/Icon'; +import { IconName } from '../../icons/IconProps'; +import { WithTestID } from 'src/shared/type'; +import { useTopAppBarMenu } from './TopAppBarMenu'; + +type TopAppBarMenuItemProps = WithTestID< + Required< + Pick, 'title' | 'onPress'> + > & { + color?: string; + iconName: IconName; + } +>; +const TopAppBarMenuItem = ({ + onPress, + title, + testID, + color, + iconName, +}: TopAppBarMenuItemProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + const { setIsOpen } = useTopAppBarMenu(); + + const handleOnPress = (e: GestureResponderEvent) => { + setIsOpen(false); + onPress(e); + }; + + const colorTheme = color || theme.sw.colors.primary.main; + + const menuItemTheme = { + colors: { + onSurface: colorTheme, + }, + }; + + const leadingIcon = () => ( + + ); + + return ( + + ); +}; + +function useStyles(theme: Theme) { + return StyleSheet.create({ + menuItem: { + borderRadius: 18, + paddingHorizontal: theme.sw.spacing.l, + paddingVertical: theme.sw.spacing.m, + height: 'auto', + }, + title: { + fontSize: 16, + fontWeight: '600', + }, + }); +} + +export default TopAppBarMenuItem; diff --git a/src/components/topAppBar/TopAppBar.tsx b/src/components/topAppBar/TopAppBar.tsx index bc721019..99d78b02 100644 --- a/src/components/topAppBar/TopAppBar.tsx +++ b/src/components/topAppBar/TopAppBar.tsx @@ -3,59 +3,38 @@ import { Appbar } from 'react-native-paper'; import { useTheme } from '../../styles/themes'; import { StyleSheet, type ViewStyle } from 'react-native'; import { Headline } from '../typography/Headline'; -import DeviceInfo from "react-native-device-info"; -import type { IconSource } from 'react-native-paper/lib/typescript/components/Icon'; +import DeviceInfo from 'react-native-device-info'; import type { WithTestID } from 'src/shared/type'; - -interface Icon { - name: IconSource; - onPress?: () => void; -} +import TopAppBarAction from './TopAppBarAction'; +import {TopAppBarMenu} from "./Menu/TopAppBarMenu"; +import TopAppBarMenuItem from "./Menu/TopAppBarMenuItem"; export interface Title { value: ReactNode; onPress?: () => void; } -export type Props = WithTestID<{ +export type TopAppBarProps = WithTestID<{ size?: 'small' | 'medium' | 'large' | 'center-aligned'; title: Title; - icon?: Icon; onBack?: () => void; style?: ViewStyle; + action?: ReactNode; }>; -export const TopAppBar = ({ +// eslint-disable-next-line react/function-component-definition +export function TopAppBar({ size = 'small', title, - icon, onBack, style, testID, -}: Props) => { + action, +}: TopAppBarProps) { const theme = useTheme(); - const isTablet = DeviceInfo.isTablet(); - const styles = StyleSheet.create({ - button: { - backgroundColor: 'rgba(145, 158, 171, 0.24)', - borderRadius: 18, - marginLeft: isTablet ? 12 : theme.sw.spacing.xs, - }, - title: { - paddingTop: size === 'medium' ? 9 : 0, - paddingBottom: 0, - justifyContent: 'flex-start', - }, - header: { - paddingHorizontal: 12, - paddingBottom: 0, - ...style, - }, - }); - const getIconColor = () => { - return theme.sw.colors.neutral[600]; - }; + const styles = useStyles(size, style); + return ( - {icon !== undefined && ( - - )} + {action} ); -}; +} +TopAppBar.Action = TopAppBarAction; +TopAppBar.Menu = TopAppBarMenu; +TopAppBar.MenuItem = TopAppBarMenuItem; + +function useStyles( + size: TopAppBarProps['size'], + style: TopAppBarProps['style'], +) { + const theme = useTheme(); + const isTablet = DeviceInfo.isTablet(); + return StyleSheet.create({ + button: { + backgroundColor: 'rgba(145, 158, 171, 0.24)', + borderRadius: 18, + marginLeft: isTablet ? 12 : theme.sw.spacing.xs, + }, + title: { + paddingTop: size === 'medium' ? 9 : 0, + paddingBottom: 0, + justifyContent: 'flex-start', + }, + header: { + paddingHorizontal: 12, + paddingBottom: 0, + ...style, + }, + }); +} diff --git a/src/components/topAppBar/TopAppBarAction.tsx b/src/components/topAppBar/TopAppBarAction.tsx new file mode 100644 index 00000000..19fa1985 --- /dev/null +++ b/src/components/topAppBar/TopAppBarAction.tsx @@ -0,0 +1,34 @@ +import React, { ComponentProps } from 'react'; +import { Appbar } from 'react-native-paper'; +import { Theme, useTheme } from '../../styles/themes'; +import DeviceInfo from 'react-native-device-info'; +import { StyleSheet } from 'react-native'; + +type TopAppBarActionProps = ComponentProps; +const TopAppBarAction = (props: TopAppBarActionProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + return ( + + ); +}; + +function useStyles(theme: Theme) { + const isTablet = DeviceInfo.isTablet(); + + return StyleSheet.create({ + button: { + backgroundColor: 'rgba(145, 158, 171, 0.24)', + borderRadius: 18, + marginLeft: isTablet ? 12 : theme.sw.spacing.xs, + }, + }); +} + +export default TopAppBarAction; diff --git a/src/index.tsx b/src/index.tsx index ae53e863..8bb5afdc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ -import { +export { Body, Button, IconButton, @@ -33,40 +33,4 @@ import { DateSelector, } from './components'; -import { ThemeProvider, useTheme } from './styles/themes'; -export { - Body, - Button, - IconButton, - ActionCard, - Dialog, - Headline, - Icon, - Logo, - Screen, - TextField, - NumberField, - Keyboard, - DropDown, - SnackBar, - Toggle, - Menu, - TopAppBar, - ThemeProvider, - useTheme, - BottomSheet, - PrintState, - EANInput, - ModifyQuantity, - NumberSelector, - Card, - Tab, - Label, - Product, - Divider, - LineChart, - Badge, - DateField, - DateSelector, - NumberValidator, -}; +export { ThemeProvider, useTheme } from './styles/themes'; diff --git a/src/shared/testUtils.tsx b/src/shared/testUtils.tsx index 92e79d4a..124d21e4 100644 --- a/src/shared/testUtils.tsx +++ b/src/shared/testUtils.tsx @@ -1,6 +1,7 @@ import React, { ReactElement } from 'react'; import { RenderOptions, + act, render as rtlRender, } from '@testing-library/react-native'; import { ThemeProvider } from '../styles/themes'; // Replace with the actual path to your ThemeProvider @@ -13,5 +14,18 @@ const uiRender = (ui: ReactElement, options?: RenderOptions) => { return rtlRender(ui, { wrapper: Wrapper, ...options }); }; +const setupFakeTimer = () => { + jest.useFakeTimers(); + + act(() => { + jest.runAllTimers(); + }); +}; + +const cleanUpFakeTimer = () => { + jest.clearAllTimers(); + jest.useRealTimers(); +}; + export * from '@testing-library/react-native'; -export { uiRender as render }; +export { uiRender as render, setupFakeTimer, cleanUpFakeTimer };