From 521b3875ae2a298780bd4ef67f69c550babfe279 Mon Sep 17 00:00:00 2001 From: zetavg Date: Wed, 17 Jan 2024 22:58:42 +0800 Subject: [PATCH 1/6] app: add components/Menu --- App/.storybook/storybook.requires.js | 1 + App/app/components/Menu/Menu.stories.tsx | 8 + App/app/components/Menu/MenuView.tsx | 148 ++++++++++ App/app/components/Menu/Sample.tsx | 166 +++++++++++ App/app/components/Menu/index.ts | 3 + App/app/components/Menu/types.ts | 74 +++++ App/app/components/ScreenContent.tsx | 349 ++++++++++++----------- App/app/screens/SampleScreen.tsx | 108 ++++++- App/ios/Podfile.lock | 6 + App/package.json | 1 + App/yarn.lock | 5 + 11 files changed, 703 insertions(+), 166 deletions(-) create mode 100644 App/app/components/Menu/Menu.stories.tsx create mode 100644 App/app/components/Menu/MenuView.tsx create mode 100644 App/app/components/Menu/Sample.tsx create mode 100644 App/app/components/Menu/index.ts create mode 100644 App/app/components/Menu/types.ts diff --git a/App/.storybook/storybook.requires.js b/App/.storybook/storybook.requires.js index 94aa83fc..518ee4ae 100644 --- a/App/.storybook/storybook.requires.js +++ b/App/.storybook/storybook.requires.js @@ -29,6 +29,7 @@ const getStories = () => { require("../app/components/ElevatedButton/ElevatedButton.stories.tsx"), require("../app/components/Icon/Icon.stories.tsx"), require("../app/components/InsetGroup/InsetGroup.stories.tsx"), + require("../app/components/Menu/Menu.stories.tsx"), require("../app/components/ModalContentScrollView/ModalContentScrollView.stories.tsx"), require("../app/components/NeomorphShadow/NeomorphShadow.stories.tsx"), require("../app/components/RubberButton/RubberButton.stories.tsx"), diff --git a/App/app/components/Menu/Menu.stories.tsx b/App/app/components/Menu/Menu.stories.tsx new file mode 100644 index 00000000..f8f8fdad --- /dev/null +++ b/App/app/components/Menu/Menu.stories.tsx @@ -0,0 +1,8 @@ +import React from 'react'; + +import SampleComponent from './Sample'; +export default { + title: '[B] Menu', +}; + +export const Sample = () => ; diff --git a/App/app/components/Menu/MenuView.tsx b/App/app/components/Menu/MenuView.tsx new file mode 100644 index 00000000..1650dfd8 --- /dev/null +++ b/App/app/components/Menu/MenuView.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useMemo } from 'react'; +import { Alert, Platform } from 'react-native'; +import { TouchableWithoutFeedback } from 'react-native-gesture-handler'; + +import { + MenuAction as RNMenuAction, + MenuView as RNMenuView, + NativeActionEvent, +} from '@react-native-menu/menu'; + +import useActionSheet from '@app/hooks/useActionSheet'; +import useLogger from '@app/hooks/useLogger'; + +import { MenuAction } from './types'; + +export type Props = { + /** The title of the menu. Currently it will only work on iOS. */ + title?: string; + /** Actions in the menu. */ + actions: ReadonlyArray; + children?: React.ReactNode | undefined; +}; + +const NATIVE_STATE_SUPPORTED = Platform.OS === 'ios'; + +function processActions( + actions: ReadonlyArray, + { idPrefix }: { idPrefix?: string } = {}, +): RNMenuAction[] { + return actions.map((action, i) => { + const id = `${idPrefix || ''}${i}`; + return { + id: id, + title: + NATIVE_STATE_SUPPORTED || !action.state + ? (action.title as any) + : `${(() => { + switch (action.state) { + case 'on': + return '☑'; + case 'off': + return '☐'; + case 'mixed': + return '☒'; + } + })()} ${action.title}`, + image: Platform.select({ + ios: action.sfSymbolName, + android: undefined, // Not supported yet + }), + state: NATIVE_STATE_SUPPORTED ? action.state : undefined, + subactions: Array.isArray(action.children) + ? processActions(action.children, { idPrefix: `${id}-` }) + : undefined, + attributes: { + destructive: action.destructive, + }, + displayInline: action.type === 'section', + }; + }); +} + +export default function MenuView({ title, actions, ...restProps }: Props) { + const logger = useLogger('MenuView'); + const processedActions = useMemo(() => processActions(actions), [actions]); + const handlePressAction = useCallback< + ({ nativeEvent }: NativeActionEvent) => void + >( + e => { + const id = e.nativeEvent.event; + const idA = id.split('-'); + + let currentActions: typeof actions | undefined = actions; + let action: MenuAction | undefined; + for (const idP of idA) { + if (!Array.isArray(currentActions)) { + logger.error( + `Cannot find action by ID "${id}". There might be a bug in the MenuView component. Please report this to the developer.`, + { showAlert: true }, + ); + return; + } + action = currentActions[parseInt(idP, 10)]; + currentActions = action?.children; + } + + if (!action) { + logger.error( + `Cannot find action by ID "${id}". There might be a bug in the MenuView component. Please report this to the developer.`, + { showAlert: true }, + ); + return; + } + + action.onPress?.(); + }, + [actions, logger], + ); + + const { showActionSheet } = useActionSheet(); + + if (Platform.OS === 'android') { + // @react-native-menu/menu will not work on Android randomly. Fall back to ActionSheet. + // See: https://github.com/react-native-menu/menu/issues/539 + const handleOpenActionSheetForMenuActions = ( + menuActions: ReadonlyArray, + ) => { + showActionSheet( + menuActions + .flatMap(a => (a.type === 'section' ? a.children : [a])) + .filter((a): a is NonNullable => !!a) + .map(a => ({ + name: !a.state + ? (a.title as any) + : `${(() => { + switch (a.state) { + case 'on': + return '☑'; + case 'off': + return '☐'; + case 'mixed': + return '☒'; + } + })()} ${a.title}`, + destructive: a.destructive, + onSelect: a.children + ? () => handleOpenActionSheetForMenuActions(a.children || []) + : a.onPress || (() => {}), + })), + ); + }; + return ( + handleOpenActionSheetForMenuActions(actions)} + {...restProps} + /> + ); + } + + return ( + + ); +} diff --git a/App/app/components/Menu/Sample.tsx b/App/app/components/Menu/Sample.tsx new file mode 100644 index 00000000..6605ae79 --- /dev/null +++ b/App/app/components/Menu/Sample.tsx @@ -0,0 +1,166 @@ +/* eslint-disable react-native/no-inline-styles */ +import React, { useMemo } from 'react'; +import { Alert, Button, Platform, Text } from 'react-native'; + +import StorybookSection from '@app/components/StorybookSection'; +import StorybookStoryContainer from '@app/components/StorybookStoryContainer'; + +import MenuView from './MenuView'; + +export default function SampleComponent() { + return ( + + + [ + { + title: 'Add', + sfSymbolName: 'plus', + children: [ + { + title: 'Take Picture from Camera', + sfSymbolName: 'camera', + onPress: () => + Alert.alert('"Take Picture from Camera" pressed'), + }, + { + title: 'Select from Photo Library', + sfSymbolName: 'photo.on.rectangle', + onPress: () => + Alert.alert('"Select from Photo Library" pressed'), + }, + { + title: 'Select from Files', + sfSymbolName: 'folder', + onPress: () => Alert.alert('"Select from Files" pressed'), + }, + ], + }, + { + title: 'Options', + sfSymbolName: 'slider.horizontal.3', + children: [ + { + title: 'On State', + state: 'on', + onPress: () => Alert.alert('"On State" pressed'), + }, + { + title: 'Off State', + state: 'off', + onPress: () => Alert.alert('"Off State" pressed'), + }, + { + title: 'Mixed State', + state: 'mixed', + onPress: () => Alert.alert('"Mixed State" pressed'), + }, + { + title: 'Destructive Action', + destructive: true, + onPress: () => Alert.alert('"Destructive Action" pressed'), + }, + ], + }, + { + title: 'Share', + sfSymbolName: 'square.and.arrow.up', + onPress: () => Alert.alert('"Share" pressed'), + }, + // { + // title: 'Disabled', + // attributes: { + // disabled: true, + // }, + // onPress: () => Alert.alert('"Disabled" pressed'), + // }, + { + title: 'Destructive', + destructive: true, + sfSymbolName: 'trash', + onPress: () => Alert.alert('"Destructive" pressed'), + }, + ], + [], + )} + > +