Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add React Native Menu #30

Merged
merged 6 commits into from
Jan 21, 2024
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
1 change: 1 addition & 0 deletions App/.storybook/storybook.requires.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
8 changes: 8 additions & 0 deletions App/app/components/Menu/Menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';

import SampleComponent from './Sample';
export default {
title: '[B] Menu',
};

export const Sample = () => <SampleComponent />;
159 changes: 159 additions & 0 deletions App/app/components/Menu/MenuView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React, { useCallback, useMemo } from 'react';
import { Alert, Platform, StyleProp, ViewStyle } 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<MenuAction>;
children?: JSX.Element | undefined;
style?: StyleProp<ViewStyle>;
disabled?: boolean;
};

const NATIVE_STATE_SUPPORTED = Platform.OS === 'ios';

function processActions(
actions: ReadonlyArray<MenuAction>,
{ 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,
disabled,
...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 (disabled) return <>{restProps.children}</> || null;

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<MenuAction>,
) => {
showActionSheet(
menuActions
.flatMap(a => (a.type === 'section' ? a.children : [a]))
.filter((a): a is NonNullable<typeof a> => !!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 (
<TouchableWithoutFeedback
onPress={() =>
!disabled && handleOpenActionSheetForMenuActions(actions)
}
{...restProps}
/>
);
}

return (
<RNMenuView
title={title}
actions={processedActions}
onPressAction={handlePressAction}
{...restProps}
/>
);
}
183 changes: 183 additions & 0 deletions App/app/components/Menu/Sample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* 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 (
<StorybookStoryContainer>
<StorybookSection title="MenuView" style={{ gap: 8 }}>
<MenuView
title="Menu Title"
actions={useMemo(
() => [
{
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'),
},
],
[],
)}
>
<Button title="Show Menu" />
</MenuView>
</StorybookSection>
<StorybookSection title="MenuView with sections" style={{ gap: 8 }}>
<MenuView
actions={useMemo(
() => [
{
type: 'section',
children: [
{
title: 'Sort by Name',
state: 'on',
onPress: () => Alert.alert('"Sort by Name" pressed'),
},
{
title: 'Sort by Date',
state: 'off',
onPress: () => Alert.alert('"Sort by Date" pressed'),
},
],
},
{
type: 'section',
children: [
{
title: 'Ascending',
state: 'on',
onPress: () => Alert.alert('"Ascending" pressed'),
},
{
title: 'Descending',
state: 'off',
onPress: () => Alert.alert('"Descending" pressed'),
},
],
},
{
type: 'section',
children: [
{
title: 'More 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'),
},
],
},
],
},
],
[],
)}
>
<Button title="Show Menu" />
</MenuView>
</StorybookSection>
<StorybookSection title="MenuView disabled" style={{ gap: 8 }}>
<MenuView
disabled
actions={useMemo(
() => [
{
title: 'Action',
onPress: () => Alert.alert('"Action" pressed'),
},
],
[],
)}
>
<Button title="Show Menu" disabled />
</MenuView>
</StorybookSection>
</StorybookStoryContainer>
);
}
3 changes: 3 additions & 0 deletions App/app/components/Menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MenuView from './MenuView';
export type { MenuAction } from './types';
export { MenuView };
Loading
Loading