├── .gitignore
├── .babelrc
├── __mocks__
├── .eslintrc
└── react-native.js
├── android.demo.gif
├── doc
├── img
│ ├── basic.png
│ ├── styled.png
│ ├── checked.png
│ └── context-menu.png
├── extensions.md
├── examples.md
└── api.md
├── android.demo-popover.gif
├── examples
├── assets
│ ├── images
│ │ ├── icon.png
│ │ ├── favicon.png
│ │ ├── react-logo.png
│ │ ├── splash-icon.png
│ │ ├── adaptive-icon.png
│ │ ├── react-logo@2x.png
│ │ ├── react-logo@3x.png
│ │ └── partial-react-logo.png
│ └── fonts
│ │ └── SpaceMono-Regular.ttf
├── app
│ ├── index.tsx
│ ├── _layout.tsx
│ └── demos
│ │ ├── BasicExample.js
│ │ ├── NonRootExample.js
│ │ ├── FlatListExample.js
│ │ ├── InFlatListExample.js
│ │ ├── ControlledExample.js
│ │ ├── ModalExample.js
│ │ ├── MenuMethodsExample.js
│ │ ├── PopoverExample.js
│ │ ├── ExtensionExample.js
│ │ ├── CloseOnBackExample.js
│ │ ├── Demo.js
│ │ ├── TouchableExample.js
│ │ ├── StylingExample.js
│ │ └── Example.js
├── .vscode
│ └── settings.json
├── eslint.config.js
├── tsconfig.json
├── eas.json
├── .gitignore
├── __tests__
│ ├── Basic-test.js
│ └── __snapshots__
│ │ └── Basic-test.js.snap
├── app.json
├── README.md
└── package.json
├── .eslintignore
├── .npmignore
├── src
├── config.js
├── polyfills.js
├── constants.js
├── logger.js
├── renderers
│ ├── NotAnimatedContextMenu.js
│ ├── MenuOutside.js
│ ├── SlideInMenu.js
│ ├── ContextMenu.js
│ └── Popover.js
├── index.js
├── with-context.js
├── MenuPlaceholder.js
├── MenuOptions.js
├── Backdrop.js
├── menuRegistry.js
├── MenuTrigger.js
├── MenuOption.js
├── helpers.js
├── Menu.js
├── index.d.ts
└── MenuProvider.js
├── __tests__
├── .eslintrc
├── Backdrop-test.js
├── renderers
│ ├── NotAnimatedContextMenu-test.js
│ ├── Popover-test.js
│ ├── SlideInMenu-test.js
│ ├── MenuOutside-test.js
│ └── ContextMenu-test.js
├── helpers.js
├── MenuOptions-test.js
├── MenuTrigger-test.js
├── menuRegistry-test.js
├── helpers-test.js
├── MenuOption-test.js
├── Menu-test.js
└── MenuProvider-test.js
├── setup-jasmine-env.js
├── .github
├── ISSUE_TEMPLATE.md
└── workflows
│ └── main.yml
├── Jenkinsfile
├── .eslintrc
├── LICENSE
├── rollup.config.babel.js
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | coverage
4 | target
5 | .expo
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["module:metro-react-native-babel-preset"]
3 | }
4 |
--------------------------------------------------------------------------------
/__mocks__/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "globals": {
3 | "jest": true,
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/android.demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/android.demo.gif
--------------------------------------------------------------------------------
/doc/img/basic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/doc/img/basic.png
--------------------------------------------------------------------------------
/doc/img/styled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/doc/img/styled.png
--------------------------------------------------------------------------------
/doc/img/checked.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/doc/img/checked.png
--------------------------------------------------------------------------------
/android.demo-popover.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/android.demo-popover.gif
--------------------------------------------------------------------------------
/doc/img/context-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/doc/img/context-menu.png
--------------------------------------------------------------------------------
/examples/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/icon.png
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | coverage/*
3 | examples/node_modules/*
4 | examples/android/*
5 | examples/ios/*
6 | build/*
7 |
--------------------------------------------------------------------------------
/examples/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/favicon.png
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | examples
2 | .babelrc
3 | Jenkinsfile
4 | coverage/
5 | target/
6 | __tests__/
7 | __mocks__/
8 | *.demo.gif
9 | doc/
10 |
--------------------------------------------------------------------------------
/examples/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/react-logo.png
--------------------------------------------------------------------------------
/examples/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/examples/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/examples/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/examples/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/examples/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/examples/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/instea/react-native-popup-menu/HEAD/examples/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | import ContextMenu from './renderers/ContextMenu';
2 |
3 | export const menuConfig = {
4 | defRenderer: ContextMenu,
5 | defRendererProps: {},
6 | }
7 |
--------------------------------------------------------------------------------
/examples/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { Text, View } from "react-native";
2 | import Demo from './demos/Demo';
3 |
4 | export default function Index() {
5 | return (
6 |
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/examples/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "expo-router";
2 |
3 | export default function RootLayout() {
4 | return (
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/polyfills.js:
--------------------------------------------------------------------------------
1 | // platform select polyfil for older RN versions
2 | import { Platform } from 'react-native';
3 |
4 | if (!Platform.select) {
5 | Platform.select = (obj) => obj[Platform.OS];
6 | }
7 |
--------------------------------------------------------------------------------
/examples/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.fixAll": "explicit",
4 | "source.organizeImports": "explicit",
5 | "source.sortMembers": "explicit"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | import { Platform } from 'react-native';
2 |
3 | // common durations of animation
4 | export const OPEN_ANIM_DURATION = 225;
5 | export const CLOSE_ANIM_DURATION = 195;
6 |
7 | export const USE_NATIVE_DRIVER = (Platform.OS !== "web");
8 |
--------------------------------------------------------------------------------
/src/logger.js:
--------------------------------------------------------------------------------
1 |
2 | export const CFG = {
3 | debug: false,
4 | }
5 | /**
6 | * Debug logger depending on `Menu.debug` static porperty.
7 | */
8 | export const debug = (...args) => {
9 | CFG.debug && console.log('react-native-popup-menu', ...args);
10 | };
11 |
--------------------------------------------------------------------------------
/examples/eslint.config.js:
--------------------------------------------------------------------------------
1 | // https://docs.expo.dev/guides/using-eslint/
2 | const { defineConfig } = require('eslint/config');
3 | const expoConfig = require('eslint-config-expo/flat');
4 |
5 | module.exports = defineConfig([
6 | expoConfig,
7 | {
8 | ignores: ['dist/*'],
9 | },
10 | ]);
11 |
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/__tests__/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "globals": {
3 | "options": true,
4 | "describe": true,
5 | "it": true,
6 | "before": true,
7 | "beforeEach": true,
8 | "after": true,
9 | "afterEach": true,
10 | "jest": true,
11 | "expect": true,
12 | "jasmine": true
13 | },
14 | "rules": {
15 | "react/jsx-key": 0,
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/setup-jasmine-env.js:
--------------------------------------------------------------------------------
1 | /*globals jasmine*/
2 |
3 | jasmine.VERBOSE = true;
4 |
5 | require('jasmine-reporters');
6 |
7 | const reporters = require('jasmine-reporters');
8 | const junitReporter = new reporters.JUnitXmlReporter({
9 | savePath: __dirname + '/target/',
10 | consolidateAll: false,
11 | });
12 | jasmine.getEnv().addReporter(junitReporter);
13 |
--------------------------------------------------------------------------------
/examples/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 16.17.4",
4 | "appVersionSource": "remote"
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {
15 | "autoIncrement": true
16 | }
17 | },
18 | "submit": {
19 | "production": {}
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | If you have question about the usage of the library, please ask question on StackOverflow with tag [react-native-popup-menu](http://stackoverflow.com/questions/tagged/react-native-popup-menu). We are subscribed to it but it also allows community to help you if we are busy.
2 |
3 | If you have found a bug, please describe your problem and expected behaviour. Additionally let us know library version, platform on which the problem happens and if possible some code snipets reproducing problem.
4 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [10.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v1
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: yarn
21 | - run: yarn lint
22 | - run: yarn test
23 | env:
24 | CI: true
25 |
--------------------------------------------------------------------------------
/Jenkinsfile:
--------------------------------------------------------------------------------
1 | node {
2 | stage 'Fetch source code'
3 | git url: 'https://github.com/instea/react-native-popup-menu.git', branch: 'master'
4 | stage 'Install dependencies'
5 | sh 'npm install'
6 | stage 'Run tests'
7 | try {
8 | sh 'npm test'
9 | } finally {
10 | step([$class: 'JUnitResultArchiver', testResults: 'target/*.xml'])
11 | step([$class: 'ArtifactArchiver', artifacts: 'coverage/**/*', fingerprint: true])
12 | step([$class: 'Mailer', notifyEveryUnstableBuild: true, recipients: "${env.DEV_MAIL}", sendToIndividuals: true])
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/examples/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 | expo-env.d.ts
11 |
12 | # Native
13 | .kotlin/
14 | *.orig.*
15 | *.jks
16 | *.p8
17 | *.p12
18 | *.key
19 | *.mobileprovision
20 |
21 | # Metro
22 | .metro-health-check*
23 |
24 | # debug
25 | npm-debug.*
26 | yarn-debug.*
27 | yarn-error.*
28 |
29 | # macOS
30 | .DS_Store
31 | *.pem
32 |
33 | # local env files
34 | .env*.local
35 |
36 | # typescript
37 | *.tsbuildinfo
38 |
39 | app-example
40 |
--------------------------------------------------------------------------------
/examples/__tests__/Basic-test.js:
--------------------------------------------------------------------------------
1 | /* globals jest, test, expect */
2 | import 'react-native';
3 | import React from 'react';
4 |
5 | jest.mock('react-native-popup-menu', () => ({
6 | Menu: 'Menu',
7 | MenuProvider: 'MenuProvider',
8 | MenuOptions: 'MenuOptions',
9 | MenuOption: 'MenuOption',
10 | MenuTrigger: 'MenuTrigger',
11 | }));
12 |
13 | import BasicExample from '../BasicExample';
14 |
15 | import renderer from 'react-test-renderer';
16 |
17 | test('renders correctly', () => {
18 | const tree = renderer.create(
19 |
20 | ).toJSON();
21 | expect(tree).toMatchSnapshot();
22 | });
23 |
--------------------------------------------------------------------------------
/src/renderers/NotAnimatedContextMenu.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { I18nManager, View } from 'react-native';
3 |
4 | import { computePosition, styles } from './ContextMenu';
5 |
6 | /**
7 | Simplified version of ContextMenu without animation.
8 | */
9 | export default class NotAnimatedContextMenu extends React.Component {
10 |
11 | render() {
12 | const { style, children, layouts, ...other } = this.props;
13 | const position = computePosition(layouts, I18nManager.isRTL);
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/src/renderers/MenuOutside.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { View, StyleSheet } from 'react-native';
3 |
4 | export const computePosition = ({ windowLayout }) => ({
5 | top: windowLayout.height,
6 | left: windowLayout.width,
7 | });
8 |
9 |
10 | const MenuOutside = props => {
11 | const { style, children, layouts, ...other } = props;
12 | const position = computePosition(layouts);
13 | return (
14 |
15 | {children}
16 |
17 | );
18 | };
19 |
20 | const styles = StyleSheet.create({
21 | options: {
22 | position: 'absolute',
23 | },
24 | });
25 |
26 | export default MenuOutside;
27 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": ["eslint:recommended", "plugin:react/recommended"],
4 | "parserOptions": {
5 | "ecmaVersion": 6,
6 | "sourceType": "module",
7 | "ecmaFeatures": {
8 | "jsx": true,
9 | "modules": true
10 | }
11 | },
12 | "plugins": ["react"],
13 |
14 | "env": {
15 | "node": true,
16 | "es6": true,
17 | "browser": true
18 | },
19 |
20 | "rules": {
21 | "no-console": 0,
22 | "comma-dangle": ["error", "always-multiline"],
23 | "no-unused-vars": ["error", { "ignoreRestSiblings": true }],
24 | "react/prop-types": 0,
25 | "react/no-did-mount-set-state": 0,
26 | "react/no-deprecated": 0
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright (c) 2016, instea
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any
6 | purpose with or without fee is hereby granted, provided that the above
7 | copyright notice and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 |
--------------------------------------------------------------------------------
/examples/app/demos/BasicExample.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Text } from 'react-native';
3 | import {
4 | Menu,
5 | MenuProvider,
6 | MenuOptions,
7 | MenuOption,
8 | MenuTrigger,
9 | } from 'react-native-popup-menu';
10 |
11 | const BasicExample = () => (
12 |
13 | Hello world!
14 |
24 |
25 | );
26 |
27 | export default BasicExample;
28 |
--------------------------------------------------------------------------------
/examples/app/demos/NonRootExample.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Text, View } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | } from 'react-native-popup-menu';
9 |
10 | const NonRootExample = () => (
11 |
12 |
13 | Hello world!
14 |
24 |
25 |
26 | );
27 |
28 | export default NonRootExample;
29 |
--------------------------------------------------------------------------------
/rollup.config.babel.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel'
2 | import commonjs from 'rollup-plugin-commonjs'
3 | import resolve from 'rollup-plugin-node-resolve'
4 | import replace from 'rollup-plugin-replace'
5 |
6 | export default {
7 | input: './src/index.js',
8 | output: {
9 | file: './build/rnpm.js',
10 | format: 'umd',
11 | name: 'ReactNativePopupMenu',
12 | sourcemap: true,
13 | },
14 |
15 | plugins: [
16 | babel({
17 | exclude: 'node_modules/**',
18 | babelrc: false,
19 | presets: [
20 | ['@babel/preset-env', { modules: false }],
21 | '@babel/preset-react',
22 | ],
23 | plugins: ['@babel/plugin-proposal-class-properties'],
24 | }),
25 | replace({
26 | 'process.env.NODE_ENV': JSON.stringify('development'),
27 | }),
28 | resolve(),
29 | commonjs(),
30 | ],
31 |
32 | external: ['react', 'react-dom', 'react-native'],
33 | }
34 |
--------------------------------------------------------------------------------
/__tests__/Backdrop-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { TouchableWithoutFeedback, View } from 'react-native';
3 | import { render } from './helpers';
4 |
5 | jest.dontMock('../src/Backdrop');
6 | const Backdrop = require('../src/Backdrop').default;
7 |
8 | const { createSpy } = jasmine;
9 |
10 | describe('Backdrop', () => {
11 |
12 | it('should render component', () => {
13 | const { output } = render(
14 |
15 | );
16 | expect(output.type).toEqual(TouchableWithoutFeedback);
17 | const view = output.props.children;
18 | expect(view.type).toEqual(View);
19 | });
20 |
21 | it('should trigger on press event', () => {
22 | const onPressSpy = createSpy();
23 | const { output } = render(
24 |
25 | );
26 | expect(output.type).toEqual(TouchableWithoutFeedback);
27 | expect(typeof output.props.onPress).toEqual('function');
28 | output.props.onPress();
29 | expect(onPressSpy).toHaveBeenCalled();
30 | });
31 |
32 | });
33 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import './polyfills';
2 | import { deprecatedComponent } from './helpers'
3 |
4 | import Menu from './Menu';
5 | import MenuProvider, { withCtx } from './MenuProvider';
6 | import MenuOption from './MenuOption';
7 | import MenuOptions from './MenuOptions';
8 | import MenuTrigger from './MenuTrigger';
9 |
10 | import ContextMenu from './renderers/ContextMenu';
11 | import NotAnimatedContextMenu from './renderers/NotAnimatedContextMenu';
12 | import SlideInMenu from './renderers/SlideInMenu';
13 | import Popover from './renderers/Popover';
14 | const renderers = { ContextMenu, SlideInMenu, NotAnimatedContextMenu, Popover };
15 |
16 | const MenuContext = deprecatedComponent(
17 | 'MenuContext is deprecated and it might be removed in future releases, use MenuProvider instead.',
18 | ['openMenu', 'toggleMenu', 'closeMenu', 'isMenuOpen'],
19 | )(MenuProvider);
20 |
21 | export {
22 | Menu as default,
23 | Menu,
24 | MenuProvider,
25 | MenuContext,
26 | MenuOption,
27 | MenuOptions,
28 | MenuTrigger,
29 | renderers,
30 | withCtx as withMenuContext,
31 | };
32 |
--------------------------------------------------------------------------------
/__tests__/renderers/NotAnimatedContextMenu-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { View, Text } from 'react-native';
3 | import { render } from '../helpers';
4 |
5 | jest.dontMock('../../src/renderers/NotAnimatedContextMenu');
6 | const { default: NotAnimatedContextMenu} = require('../../src/renderers/NotAnimatedContextMenu');
7 |
8 | describe('NotAnimatedContextMenu', () => {
9 |
10 | const defaultLayouts = {
11 | windowLayout: { width: 400, height: 600 },
12 | triggerLayout: { width: 50, height: 50, x: 10, y: 10 },
13 | optionsLayout: { width: 200, height: 100 },
14 | };
15 |
16 | describe('renderer', () => {
17 | it('should render component', () => {
18 | const { output } = render(
19 |
20 | Some text
21 | Other text
22 |
23 | );
24 | expect(output.type).toEqual(View);
25 | expect(output.props.children).toEqual([
26 | Some text,
27 | Other text,
28 | ]);
29 | });
30 | });
31 |
32 | });
33 |
--------------------------------------------------------------------------------
/examples/app/demos/FlatListExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { FlatList, Alert, StyleSheet } from 'react-native';
3 | import {
4 | MenuProvider,
5 | Menu,
6 | MenuTrigger,
7 | MenuOptions,
8 | MenuOption,
9 | } from 'react-native-popup-menu';
10 |
11 | Menu.debug = true;
12 |
13 | const data = new Array(500)
14 | .fill(0)
15 | .map((a, i) => ({ key: i, value: 'item' + i }));
16 |
17 | export default class App extends Component {
18 | render() {
19 | return (
20 |
21 |
33 |
34 | );
35 | }
36 | }
37 |
38 | const styles = StyleSheet.create({
39 | container: {
40 | flex: 1,
41 | paddingTop: 20,
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/examples/__tests__/__snapshots__/Basic-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders correctly 1`] = `
4 |
12 |
17 | Hello world!
18 |
19 |
53 |
54 | `;
55 |
--------------------------------------------------------------------------------
/src/with-context.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export function withContext(Context, propName = "context") {
4 | return function wrap(Component) {
5 | class EnhanceContext extends React.Component {
6 | render() {
7 | const { forwardedRef, ...rest } = this.props;
8 |
9 | return (
10 |
11 | {value => {
12 | const custom = {
13 | [propName]: value,
14 | ref: forwardedRef,
15 | };
16 | return ;
17 | }}
18 |
19 | );
20 | }
21 | }
22 |
23 | const name = Component.displayName || Component.name || "Component";
24 | const consumerName =
25 | Context.Consumer.displayName ||
26 | Context.Consumer.name ||
27 | "Context.Consumer";
28 |
29 | function enhanceForwardRef(props, ref) {
30 | return ;
31 | }
32 |
33 | enhanceForwardRef.displayName = `enhanceContext-${consumerName}(${name})`;
34 |
35 | const FC = React.forwardRef(enhanceForwardRef);
36 | return FC
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/__tests__/renderers/Popover-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { View, Animated, Text } from 'react-native';
3 | import { render } from '../helpers';
4 |
5 | jest.dontMock('../../src/renderers/Popover');
6 | const { default: Popover } = require('../../src/renderers/Popover');
7 |
8 | describe('Popover', () => {
9 |
10 | const defaultLayouts = {
11 | windowLayout: { width: 400, height: 600, x: 0, y: 0 },
12 | triggerLayout: { width: 50, height: 50, x: 10, y: 10 },
13 | optionsLayout: { width: 200, height: 100 },
14 | };
15 |
16 | describe('renderer', () => {
17 | it('should render component', () => {
18 | const { output } = render(
19 |
20 | Some text
21 | Other text
22 |
23 | );
24 | expect(output.type).toEqual(Animated.View);
25 | const anchor = output.props.children[0]
26 | expect(anchor.type).toEqual(View);
27 | const content = output.props.children[1]
28 | expect(content.type).toEqual(View);
29 | expect(content.props.children).toEqual([
30 | Some text,
31 | Other text,
32 | ]);
33 | });
34 | });
35 |
36 | });
37 |
--------------------------------------------------------------------------------
/examples/app/demos/InFlatListExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { FlatList, Alert, StyleSheet } from 'react-native';
3 | import {
4 | MenuProvider,
5 | Menu,
6 | MenuTrigger,
7 | MenuOptions,
8 | MenuOption,
9 | } from 'react-native-popup-menu';
10 |
11 | const data = new Array(100)
12 | .fill(0)
13 | .map((a, i) => ({ key: '' + i, value: 'item' + i }));
14 |
15 | export default class App extends Component {
16 | render() {
17 | return (
18 |
19 | (
22 |
30 | )}
31 | style={{ height: 200 }}
32 | />
33 |
34 | );
35 | }
36 | }
37 |
38 | const styles = StyleSheet.create({
39 | container: {
40 | flex: 1,
41 | paddingTop: 20,
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/src/MenuPlaceholder.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { View, StyleSheet } from 'react-native';
3 | import Backdrop from './Backdrop';
4 | import { debug } from './logger.js';
5 |
6 | export default class MenuPlaceholder extends Component {
7 | constructor(props) {
8 | super(props)
9 | this.state = {};
10 | }
11 |
12 | shouldComponentUpdate() {
13 | // don't terminate closing animation
14 | return !this.props.ctx._isMenuClosing;
15 | }
16 |
17 | render() {
18 | const { ctx, backdropStyles } = this.props;
19 | const shouldRenderMenu = ctx.isMenuOpen() && ctx._isInitialized();
20 | debug('MenuPlaceholder should render', shouldRenderMenu);
21 | if (!shouldRenderMenu) {
22 | return null;
23 | }
24 | return (
25 |
26 |
31 | {
32 | ctx._makeOptions()
33 | }
34 |
35 | );
36 | }
37 | }
38 |
39 | const styles = StyleSheet.create({
40 | placeholder: {
41 | position: 'absolute',
42 | top: 0,
43 | left: 0,
44 | right: 0,
45 | bottom: 0,
46 | overflow: 'hidden',
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/MenuOptions.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { View } from 'react-native';
4 | import { withCtx } from './MenuProvider';
5 |
6 | export class MenuOptions extends React.Component {
7 |
8 | updateCustomStyles(_props) {
9 | const { customStyles = {} } = _props
10 | const menu = this.props.ctx.menuActions._getOpenedMenu()
11 | // FIXME react 16.3 workaround for ControlledExample!
12 | if (!menu) return
13 | const menuName = menu.instance.getName()
14 | this.props.ctx.menuRegistry.setOptionsCustomStyles(menuName, customStyles)
15 | }
16 |
17 | componentDidMount() {
18 | this.updateCustomStyles(this.props)
19 | }
20 |
21 | componentDidUpdate() {
22 | this.updateCustomStyles(this.props)
23 | }
24 |
25 | render() {
26 | const { customStyles = {}, style, children } = this.props
27 | return (
28 |
29 | {children}
30 |
31 | )
32 | }
33 | }
34 |
35 | MenuOptions.propTypes = {
36 | customStyles: PropTypes.object,
37 | renderOptionsContainer: PropTypes.func,
38 | optionsContainerStyle: PropTypes.oneOfType([
39 | PropTypes.object,
40 | PropTypes.number,
41 | PropTypes.array,
42 | ]),
43 | };
44 |
45 | export default withCtx(MenuOptions);
46 |
--------------------------------------------------------------------------------
/__tests__/renderers/SlideInMenu-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Animated, Text } from 'react-native';
3 | import { render } from '../helpers';
4 |
5 | jest.dontMock('../../src/renderers/SlideInMenu');
6 | const { default: SlideInMenu, computePosition } = require('../../src/renderers/SlideInMenu');
7 |
8 | describe('SlideInMenu', () => {
9 |
10 | const defaultLayouts = {
11 | windowLayout: { width: 400, height: 600 },
12 | optionsLayout: { width: 50, height: 100 },
13 | };
14 |
15 | it('should render component', () => {
16 | const { output } = render(
17 |
18 | Some text
19 | Other text
20 |
21 | );
22 | expect(output.type).toEqual(Animated.View);
23 | expect(output.props.children).toEqual([
24 | Some text,
25 | Other text,
26 | ]);
27 | });
28 |
29 | describe('computePosition', () => {
30 | it('should compute position at the bottom', () => {
31 | const windowLayout = { width: 400, height: 600 };
32 | const optionsLayout = { width: 400, height: 100 };
33 | const layouts = { windowLayout, optionsLayout };
34 | expect(computePosition(layouts)).toEqual({
35 | top: 500, left: 0, right: 0,
36 | });
37 | });
38 | });
39 |
40 | });
41 |
--------------------------------------------------------------------------------
/examples/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "RNPopupMenuExamples",
4 | "slug": "RNPopupMenuExamples",
5 | "version": "1.1.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "reactnativepopupmenuexamples",
9 | "userInterfaceStyle": "automatic",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true
13 | },
14 | "android": {
15 | "adaptiveIcon": {
16 | "foregroundImage": "./assets/images/adaptive-icon.png",
17 | "backgroundColor": "#ffffff"
18 | },
19 | "package": "sk.instea.rnpopupmenuexamples",
20 | "edgeToEdgeEnabled": true
21 | },
22 | "web": {
23 | "bundler": "metro",
24 | "output": "static",
25 | "favicon": "./assets/images/favicon.png"
26 | },
27 | "plugins": [
28 | "expo-router",
29 | [
30 | "expo-splash-screen",
31 | {
32 | "image": "./assets/images/splash-icon.png",
33 | "imageWidth": 200,
34 | "resizeMode": "contain",
35 | "backgroundColor": "#ffffff"
36 | }
37 | ]
38 | ],
39 | "experiments": {
40 | "typedRoutes": true
41 | },
42 | "extra": {
43 | "router": {},
44 | "eas": {
45 | "projectId": "bb486290-87aa-4799-84e2-fe1d0e93c74d"
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/__tests__/renderers/MenuOutside-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { View, Text } from 'react-native';
3 | import { render } from '../helpers';
4 |
5 | jest.dontMock('../../src/renderers/MenuOutside');
6 | const { default: MenuOutside, computePosition } = require('../../src/renderers/MenuOutside');
7 |
8 | describe('MenuOutside', () => {
9 |
10 | const defaultLayouts = {
11 | windowLayout: { width: 400, height: 600 },
12 | triggerLayout: { width: 50, height: 50, x: 10, y: 10 },
13 | optionsLayout: { width: 200, height: 100 },
14 | };
15 |
16 | describe('renderer', () => {
17 | it('should render component', () => {
18 | const { output } = render(
19 |
20 | Some text
21 | Other text
22 |
23 | );
24 | expect(output.type).toEqual(View);
25 | expect(output.props.children).toEqual([
26 | Some text,
27 | Other text,
28 | ]);
29 | });
30 | });
31 |
32 | describe('computePosition', () => {
33 | it('should compute position outside of the screen', () => {
34 | const windowLayout = { width: 400, height: 600 };
35 | const layouts = { windowLayout };
36 | expect(computePosition(layouts)).toEqual({
37 | top: 600, left: 400,
38 | });
39 | });
40 | });
41 |
42 | });
43 |
--------------------------------------------------------------------------------
/examples/app/demos/ControlledExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Text } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | } from 'react-native-popup-menu';
9 |
10 | export default class ControlledExample extends Component {
11 |
12 | constructor(props, ctx) {
13 | super(props, ctx);
14 | this.state = { opened: true };
15 | }
16 |
17 | onOptionSelect(value) {
18 | alert(`Selected number: ${value}`);
19 | this.setState({ opened: false });
20 | }
21 |
22 | onTriggerPress() {
23 | this.setState({ opened: true });
24 | }
25 |
26 | onBackdropPress() {
27 | this.setState({ opened: false });
28 | }
29 |
30 | render() {
31 | const { opened } = this.state;
32 | console.log('ControlledExample - opened', opened)
33 | return (
34 |
36 | Hello world!
37 |
52 |
53 | );
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/examples/app/demos/ModalExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Text, Modal } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | } from 'react-native-popup-menu';
9 |
10 | class ModalExample extends Component {
11 |
12 | constructor(props, ctx) {
13 | super(props, ctx);
14 | this.state = { visible: false };
15 | }
16 |
17 | render() {
18 | return (
19 |
20 | Main window:
21 |
27 | this.setState({ visible: false })}>
28 |
29 | Modal window:
30 |
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | }
45 |
46 | export default ModalExample;
47 |
--------------------------------------------------------------------------------
/examples/app/demos/MenuMethodsExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Text, TouchableOpacity } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | withMenuContext,
9 | } from 'react-native-popup-menu';
10 |
11 | const Openner = (props) => (
12 | props.ctx.menuActions.openMenu('menu-1')}>
14 | Open menu from context
15 |
16 | );
17 |
18 | const ContextOpenner = withMenuContext(Openner);
19 |
20 | export default class ControlledExample extends Component {
21 |
22 | onOptionSelect(value) {
23 | alert(`Selected number: ${value}`);
24 | if (value === 1) {
25 | this.menu.close();
26 | }
27 | return false;
28 | }
29 |
30 | openMenu() {
31 | this.menu.open();
32 | }
33 |
34 | onRef = r => {
35 | this.menu = r;
36 | }
37 |
38 | render() {
39 | return (
40 |
41 |
49 | this.openMenu()}>
50 | Open menu from outside
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/src/Backdrop.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { View, StyleSheet, TouchableWithoutFeedback, Animated } from 'react-native';
4 | import { OPEN_ANIM_DURATION, CLOSE_ANIM_DURATION, USE_NATIVE_DRIVER } from './constants';
5 |
6 | class Backdrop extends Component {
7 |
8 | constructor(...args) {
9 | super(...args);
10 | this.fadeAnim = new Animated.Value(0.001);
11 | }
12 |
13 | open() {
14 | return new Promise(resolve => {
15 | Animated.timing(this.fadeAnim, {
16 | duration: OPEN_ANIM_DURATION,
17 | toValue: 1,
18 | useNativeDriver: USE_NATIVE_DRIVER,
19 | }).start(resolve);
20 | });
21 | }
22 |
23 | close() {
24 | return new Promise(resolve => {
25 | Animated.timing(this.fadeAnim, {
26 | duration: CLOSE_ANIM_DURATION,
27 | toValue: 0,
28 | useNativeDriver: USE_NATIVE_DRIVER,
29 | }).start(resolve);
30 | });
31 | }
32 |
33 | render() {
34 | const { onPress, style } = this.props;
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | }
45 |
46 | Backdrop.propTypes = {
47 | onPress: PropTypes.func.isRequired,
48 | };
49 |
50 | const styles = StyleSheet.create({
51 | fullscreen: {
52 | opacity: 0,
53 | position: 'absolute',
54 | top: 0,
55 | left: 0,
56 | bottom: 0,
57 | right: 0,
58 | },
59 | });
60 |
61 | export default Backdrop;
62 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Expo app 👋
2 |
3 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
4 |
5 | ## Get started
6 |
7 | 1. Install dependencies
8 |
9 | ```bash
10 | npm install
11 | ```
12 |
13 | 2. Start the app
14 |
15 | ```bash
16 | npx expo start
17 | ```
18 |
19 | When using WSL, you might need to use tunnel
20 |
21 | ```bash
22 | npx expo start --tunnel
23 | ```
24 |
25 | In the output, you'll find options to open the app in a
26 |
27 | - [development build](https://docs.expo.dev/develop/development-builds/introduction/)
28 | - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
29 | - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
30 | - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
31 |
32 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
33 |
34 | ## Publish update
35 |
36 | ### Prerequisites
37 |
38 | ```
39 | # expo
40 | npm install -g eas-cli
41 | eas login
42 | ```
43 |
44 | ### Building
45 |
46 | Update the version in `app.json`
47 |
48 | ```
49 | {
50 | "expo": {
51 | "version": "1.1.0",
52 | }
53 | }
54 | ```
55 |
56 | ```
57 | eas build --platform android --profile production
58 | ```
59 |
60 | You might need to configure project on your machine (use/download existing keystore)
61 |
62 | Then submit the application
63 |
64 | ```
65 | eas submit --platform android
66 | ```
67 |
68 | Then you can go to the google play console and publish the release.
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-popup-menu-examples",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo start --android",
9 | "ios": "expo start --ios",
10 | "web": "expo start --web",
11 | "lint": "expo lint"
12 | },
13 | "dependencies": {
14 | "@expo/vector-icons": "^15.0.3",
15 | "@react-navigation/bottom-tabs": "^7.3.10",
16 | "@react-navigation/elements": "^2.3.8",
17 | "@react-navigation/native": "^7.1.6",
18 | "expo": "~54.0.0",
19 | "expo-blur": "~15.0.8",
20 | "expo-constants": "~18.0.12",
21 | "expo-font": "~14.0.10",
22 | "expo-haptics": "~15.0.8",
23 | "expo-image": "~3.0.11",
24 | "expo-linking": "~8.0.10",
25 | "expo-router": "~6.0.7",
26 | "expo-splash-screen": "~31.0.12",
27 | "expo-status-bar": "~3.0.9",
28 | "expo-symbols": "~1.0.8",
29 | "expo-system-ui": "~6.0.9",
30 | "expo-web-browser": "~15.0.10",
31 | "react": "19.1.0",
32 | "react-dom": "19.1.0",
33 | "react-native": "0.81.5",
34 | "react-native-gesture-handler": "~2.28.0",
35 | "react-native-popup-menu": "^0.18.0",
36 | "react-native-reanimated": "~4.1.1",
37 | "react-native-screens": "~4.16.0",
38 | "react-native-web": "^0.21.0",
39 | "react-native-webview": "13.15.0",
40 | "react-native-worklets": "0.5.1"
41 | },
42 | "devDependencies": {
43 | "@babel/core": "^7.25.2",
44 | "@types/react": "~19.1.0",
45 | "eslint": "^9.25.0",
46 | "eslint-config-expo": "~10.0.0",
47 | "typescript": "~5.9.2"
48 | },
49 | "private": true
50 | }
51 |
--------------------------------------------------------------------------------
/examples/app/demos/PopoverExample.js:
--------------------------------------------------------------------------------
1 | import {
2 | Menu,
3 | MenuProvider,
4 | MenuOptions,
5 | MenuTrigger,
6 | renderers,
7 | } from 'react-native-popup-menu';
8 | import { Text, View, StyleSheet } from 'react-native';
9 | import React from 'react';
10 |
11 | const { Popover } = renderers
12 |
13 | const MyPopover = () => (
14 |
22 | )
23 |
24 | const Row = () => (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 |
35 | const PopoverExample = () => (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 |
48 | const styles = StyleSheet.create({
49 | container: {
50 | padding: 10,
51 | flexDirection: 'column',
52 | justifyContent: 'space-between',
53 | backgroundColor: 'rgba(0, 0, 0, 0.05)',
54 | },
55 | row: {
56 | flexDirection: 'row',
57 | justifyContent: 'space-between',
58 | },
59 | backdrop: {
60 | },
61 | menuOptions: {
62 | padding: 50,
63 | },
64 | menuTrigger: {
65 | padding: 5,
66 | },
67 | triggerText: {
68 | fontSize: 20,
69 | },
70 | contentText: {
71 | fontSize: 18,
72 | },
73 | })
74 |
75 | export default PopoverExample;
76 |
--------------------------------------------------------------------------------
/__mocks__/react-native.js:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const ReactNative = React;
3 |
4 | ReactNative.StyleSheet = {
5 | create: function create(styles) {
6 | return styles;
7 | },
8 | };
9 |
10 | class View extends React.Component {
11 | render() { return false; }
12 | }
13 |
14 | View.propTypes = {
15 | style: () => null,
16 | };
17 |
18 | class ListView extends React.Component {
19 | static DataSource() {
20 | }
21 | }
22 |
23 | class AppRegistry {
24 | static registerComponent () {
25 | }
26 | }
27 |
28 | const Animated = {
29 | timing: () => ({ start: () => undefined }),
30 | Value: () => ({ interpolate: () => false }),
31 | View: View,
32 | };
33 |
34 | const I18nManager = {
35 | isRTL: false,
36 | };
37 |
38 | const Text = () => "Text";
39 | const TouchableHighlight = () => false;
40 | const TouchableWithoutFeedback = () => false;
41 | const TouchableNativeFeedback = () => false;
42 | const TouchableOpacity = () => false;
43 | const ToolbarAndroid = () => false;
44 | const Image = () => false;
45 | const ScrollView = () => false;
46 | const Platform = {
47 | select: jest.fn(o => o.ios),
48 | };
49 | const PixelRatio = {
50 | roundToNearestPixel: n => n,
51 | }
52 | const BackHandler = {
53 | addEventListener: jest.fn(),
54 | removeEventListener: jest.fn(),
55 | }
56 |
57 | ReactNative.View = View;
58 | ReactNative.ScrollView = ScrollView;
59 | ReactNative.ListView = ListView;
60 | ReactNative.Text = Text;
61 | ReactNative.TouchableOpacity = TouchableOpacity;
62 | ReactNative.TouchableHighlight = TouchableHighlight;
63 | ReactNative.TouchableNativeFeedback = TouchableNativeFeedback;
64 | ReactNative.TouchableWithoutFeedback = TouchableWithoutFeedback;
65 | ReactNative.ToolbarAndroid = ToolbarAndroid;
66 | ReactNative.Image = Image;
67 | ReactNative.AppRegistry = AppRegistry;
68 | ReactNative.Animated = Animated;
69 | ReactNative.I18nManager = I18nManager;
70 | ReactNative.Platform = Platform;
71 | ReactNative.PixelRatio = PixelRatio;
72 | ReactNative.BackHandler = BackHandler;
73 |
74 | module.exports = ReactNative;
75 |
--------------------------------------------------------------------------------
/__tests__/helpers.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ShallowRenderer from 'react-test-renderer/shallow';
3 |
4 | /**
5 | * Renders component and returns instance object and rendered output.
6 | */
7 | export function render(element, ctx) {
8 | const renderer = new ShallowRenderer();
9 | if (ctx) {
10 | element = React.cloneElement(element, { ctx })
11 | }
12 | renderer.render(element);
13 | const instance = renderer.getMountedInstance();
14 | const output = renderer.getRenderOutput();
15 | return { output, instance, renderer };
16 | }
17 |
18 | /**
19 | * Merge styles (possible array) into single style object.
20 | */
21 | export function normalizeStyle(styles) {
22 | if (Array.isArray(styles)) {
23 | return styles.reduce((r, s) => Object.assign(r, s), {});
24 | }
25 | return styles;
26 | }
27 |
28 | /**
29 | Enable debug logs
30 | */
31 | export function showDebug() {
32 | jest.mock('../src/logger', ()=> ({
33 | debug : (...args) => {
34 | console.log('test-debug', ...args);
35 | },
36 | }));
37 | }
38 |
39 | /**
40 | Creates a mock of react instance
41 | */
42 | export function mockReactInstance() {
43 | const instance = {
44 | state: {},
45 | };
46 | instance.setState = (newState, after) => {
47 | Object.assign(instance.state, newState);
48 | after && after();
49 | }
50 | return instance;
51 | }
52 |
53 | const WAIT_STEP = 50;
54 | export function waitFor(condition, timeout = 200) {
55 | const startTime = new Date().getTime();
56 | return new Promise((resolve, reject) => {
57 | const check = () => {
58 | const t = new Date().getTime();
59 | console.log('Checking condition at time:', t - startTime);
60 | if (condition()) {
61 | return resolve();
62 | }
63 | if (t > startTime + timeout) {
64 | return reject();
65 | }
66 | setTimeout(check, Math.min(WAIT_STEP, startTime + timeout - t));
67 | };
68 | check();
69 | });
70 | }
71 |
72 | export function nthChild(node, n) {
73 | return n === 0 ? node : nthChild(node.props.children, n - 1);
74 | }
75 |
--------------------------------------------------------------------------------
/src/menuRegistry.js:
--------------------------------------------------------------------------------
1 | import { iterator2array } from './helpers';
2 |
3 | /**
4 | * Registry to subscribe, unsubscribe and update data of menus.
5 | *
6 | * menu data: {
7 | * instance: react instance
8 | * triggerLayout: Object - layout of menu trigger if known
9 | * optionsLayout: Object - layout of menu options if known
10 | * optionsCustomStyles: Object - custom styles of options
11 | * }
12 | */
13 | export default function makeMenuRegistry(menus = new Map()) {
14 |
15 | /**
16 | * Subscribes menu instance.
17 | */
18 | function subscribe(instance) {
19 | const name = instance.getName()
20 | if (menus.get(name)) {
21 | console.warn(`incorrect usage of popup menu - menu with name ${name} already exists`);
22 | }
23 | menus.set(name, { name, instance });
24 | }
25 |
26 | /**
27 | * Unsubscribes menu instance.
28 | */
29 | function unsubscribe(instance) {
30 | menus.delete(instance.getName());
31 | }
32 |
33 | /**
34 | * Updates layout infomration.
35 | */
36 | function updateLayoutInfo(name, layouts = {}) {
37 | if (!menus.has(name)) {
38 | return;
39 | }
40 | const menu = Object.assign({}, menus.get(name));
41 | if (layouts.hasOwnProperty('triggerLayout')) {
42 | menu.triggerLayout = layouts.triggerLayout;
43 | }
44 | if (layouts.hasOwnProperty('optionsLayout')) {
45 | menu.optionsLayout = layouts.optionsLayout;
46 | }
47 | menus.set(name, menu);
48 | }
49 |
50 | function setOptionsCustomStyles(name, optionsCustomStyles) {
51 | if (!menus.has(name)) {
52 | return;
53 | }
54 | const menu = { ...menus.get(name), optionsCustomStyles };
55 | menus.set(name, menu);
56 | }
57 |
58 | /**
59 | * Get `menu data` by name.
60 | */
61 | function getMenu(name) {
62 | return menus.get(name);
63 | }
64 |
65 | /**
66 | * Returns all subscribed menus as array of `menu data`
67 | */
68 | function getAll() {
69 | return iterator2array(menus.values());
70 | }
71 |
72 | return { subscribe, unsubscribe, updateLayoutInfo, getMenu, getAll, setOptionsCustomStyles };
73 | }
74 |
--------------------------------------------------------------------------------
/src/MenuTrigger.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { View, Text } from 'react-native';
4 | import { debug } from './logger.js';
5 | import { makeTouchable } from './helpers';
6 | import { withCtx } from './MenuProvider';
7 |
8 | export class MenuTrigger extends Component {
9 |
10 | _onPress() {
11 | debug('trigger onPress');
12 | this.props.onPress && this.props.onPress();
13 | this.props.ctx.menuActions.openMenu(this.props.menuName);
14 | }
15 |
16 | render() {
17 | const { disabled, onRef, text, children, style, customStyles = {}, menuName,
18 | triggerOnLongPress, onAlternativeAction, testID, accessible, accessibilityRole, accessibilityLabel, ...other } = this.props;
19 |
20 | const onPress = () => !disabled && this._onPress();
21 | const { Touchable, defaultTouchableProps } = makeTouchable(customStyles.TriggerTouchableComponent);
22 | return (
23 |
24 |
34 |
35 | {text ? {text} : children}
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | }
43 |
44 | MenuTrigger.propTypes = {
45 | disabled: PropTypes.bool,
46 | text: PropTypes.string,
47 | onPress: PropTypes.func,
48 | onAlternativeAction: PropTypes.func,
49 | customStyles: PropTypes.object,
50 | triggerOnLongPress: PropTypes.bool,
51 | testID: PropTypes.string,
52 | accessible: PropTypes.bool,
53 | accessibilityRole: PropTypes.string,
54 | accessibilityLabel: PropTypes.string,
55 | };
56 |
57 | export default withCtx(MenuTrigger)
58 |
--------------------------------------------------------------------------------
/examples/app/demos/ExtensionExample.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Text, View } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | renderers,
9 | } from 'react-native-popup-menu';
10 | import Icon from 'react-native-vector-icons/FontAwesome';
11 |
12 | const CheckedOption = (props) => (
13 |
17 | )
18 |
19 | const IconOption = ({iconName, text, value}) => (
20 |
21 |
22 |
23 | {' ' + text}
24 |
25 |
26 | )
27 |
28 | const { computePosition } = renderers.ContextMenu;
29 | const roundedStyles = {
30 | backgroundColor: 'yellow',
31 | borderRadius: 30,
32 | }
33 | class RoundedContextMenu extends React.Component {
34 | render() {
35 | const { style, children, layouts, ...other } = this.props;
36 | const position = computePosition(layouts);
37 | return (
38 |
39 | {children}
40 |
41 | );
42 | }
43 | }
44 |
45 |
46 | /* You can set default renderer for all menus just once in your application: */
47 | //Menu.setDefaultRenderer(renderers.NotAnimatedContextMenu);
48 |
49 | const ExtensionExample = () => (
50 |
51 | Extensible hello world!
52 |
63 |
70 |
71 | );
72 |
73 | export default ExtensionExample;
74 |
--------------------------------------------------------------------------------
/examples/app/demos/CloseOnBackExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Text, Button } from "react-native";
3 | import {
4 | Menu,
5 | MenuProvider,
6 | MenuOptions,
7 | MenuOption,
8 | MenuTrigger,
9 | } from "react-native-popup-menu";
10 |
11 | class CloseOnBackExample extends Component {
12 | state = {
13 | customBackHandler: false,
14 | additionalMenu: false,
15 | };
16 |
17 | customBackHandler = (instance) => {
18 | alert(
19 | `Back button was pressed. Current menu state: ${
20 | instance.isMenuOpen() ? "opened" : "closed"
21 | }`
22 | );
23 | return true;
24 | };
25 |
26 | render() {
27 | const { additionalMenu } = this.state;
28 | return (
29 |
35 |
69 | );
70 | }
71 | }
72 |
73 | export default CloseOnBackExample;
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-popup-menu",
3 | "version": "0.18.0",
4 | "description": "extensible popup/context menu for react native",
5 | "main": "build/rnpm.js",
6 | "directories": {
7 | "example": "examples"
8 | },
9 | "scripts": {
10 | "build": "rollup -c rollup.config.babel.js",
11 | "prepublish": "yarn build",
12 | "test": "jest",
13 | "lint": "eslint ."
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+ssh://git@github.com:instea/react-native-popup-menu.git"
18 | },
19 | "author": "instea.co",
20 | "license": "ISC",
21 | "bugs": {
22 | "url": "https://github.com/instea/react-native-popup-menu/issues"
23 | },
24 | "homepage": "https://github.com/instea/react-native-popup-menu",
25 | "typings": "src/index.d.ts",
26 | "jest": {
27 | "transform": {
28 | ".*": "/node_modules/babel-jest"
29 | },
30 | "testRegex": ".*-test.js",
31 | "testPathIgnorePatterns": [
32 | "/node_modules/",
33 | "/examples/"
34 | ],
35 | "moduleFileExtensions": [
36 | "js"
37 | ],
38 | "unmockedModulePathPatterns": [
39 | "/node_modules/jasmine-reporters",
40 | "/node_modules/react",
41 | "/node_modules/chai",
42 | "/__tests__/helpers.js",
43 | "/node_modules/fbjs"
44 | ],
45 | "verbose": true,
46 | "collectCoverage": true,
47 | "setupTestFrameworkScriptFile": "/setup-jasmine-env.js"
48 | },
49 | "devDependencies": {
50 | "@babel/core": "^7.2.2",
51 | "@babel/plugin-proposal-class-properties": "^7.3.0",
52 | "@babel/preset-env": "^7.3.1",
53 | "@babel/preset-react": "^7.0.0",
54 | "@babel/runtime": "^7.3.1",
55 | "babel-eslint": "^7.2.3",
56 | "babel-jest": "^24.1.0",
57 | "babel-polyfill": "^6.23.0",
58 | "chai": "^3.5.0",
59 | "eslint": "^4.18.2",
60 | "eslint-plugin-react": "^7.0.0",
61 | "jasmine-reporters": "^2.1.1",
62 | "jest-cli": "^24.1.0",
63 | "metro-react-native-babel-preset": "^0.51.1",
64 | "prop-types": "^15.5.10",
65 | "react": "^16.3.1",
66 | "react-dom": "^16.3.1",
67 | "react-test-renderer": "^16.3.1",
68 | "rollup": "^1.1.2",
69 | "rollup-plugin-babel": "^4.3.2",
70 | "rollup-plugin-commonjs": "^9.2.0",
71 | "rollup-plugin-node-resolve": "^4.0.0",
72 | "rollup-plugin-replace": "^2.1.0",
73 | "sinon": "^2.2.0"
74 | },
75 | "dependencies": {}
76 | }
77 |
--------------------------------------------------------------------------------
/src/renderers/SlideInMenu.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Animated, StyleSheet, Easing } from 'react-native';
3 | import { OPEN_ANIM_DURATION, CLOSE_ANIM_DURATION, USE_NATIVE_DRIVER } from '../constants';
4 |
5 | export const computePosition = (layouts) => {
6 | const { windowLayout, optionsLayout } = layouts
7 | const { height: wHeight } = windowLayout;
8 | const { height: oHeight } = optionsLayout;
9 | const top = wHeight - oHeight;
10 | const left = 0, right = 0;
11 | const position = { top, left, right };
12 | // TODO what is the best way to handle safeArea?
13 | // most likely some extra paddings inside SlideInMenu
14 | return position;
15 | }
16 |
17 | export default class SlideInMenu extends React.Component {
18 |
19 | constructor(props) {
20 | super(props);
21 | this.state = {
22 | slide: new Animated.Value(0),
23 | };
24 | }
25 |
26 | componentDidMount() {
27 | Animated.timing(this.state.slide, {
28 | duration: OPEN_ANIM_DURATION,
29 | toValue: 1,
30 | easing: Easing.out(Easing.cubic),
31 | useNativeDriver: USE_NATIVE_DRIVER,
32 | }).start();
33 | }
34 |
35 | close() {
36 | return new Promise(resolve => {
37 | Animated.timing(this.state.slide, {
38 | duration: CLOSE_ANIM_DURATION,
39 | toValue: 0,
40 | easing: Easing.in(Easing.cubic),
41 | useNativeDriver: USE_NATIVE_DRIVER,
42 | }).start(resolve);
43 | });
44 | }
45 |
46 | render() {
47 | const { style, children, layouts, ...other } = this.props;
48 | const { height: oHeight } = layouts.optionsLayout;
49 | const animation = {
50 | transform: [{
51 | translateY: this.state.slide.interpolate({
52 | inputRange: [0, 1],
53 | outputRange: [oHeight, 0],
54 | }),
55 | }],
56 | };
57 | const position = computePosition(layouts);
58 | return (
59 |
60 | {children}
61 |
62 | );
63 | }
64 | }
65 |
66 | const styles = StyleSheet.create({
67 | options: {
68 | position: 'absolute',
69 | backgroundColor: 'white',
70 |
71 | // Shadow only works on iOS.
72 | shadowColor: 'black',
73 | shadowOpacity: 0.3,
74 | shadowOffset: { width: 3, height: 3 },
75 | shadowRadius: 4,
76 |
77 | // This will elevate the view on Android, causing shadow to be drawn.
78 | elevation: 5,
79 | },
80 | });
81 |
--------------------------------------------------------------------------------
/__tests__/MenuOptions-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { View } from 'react-native';
3 | import { render } from './helpers';
4 | import { MenuOption } from '../src/index';
5 |
6 | jest.dontMock('../src/MenuOptions');
7 | const { MenuOptions } = require('../src/MenuOptions');
8 |
9 | describe('MenuOptions', () => {
10 |
11 | function mockCtx() {
12 | return {
13 | menuActions: {
14 | _getOpenedMenu: () => ({
15 | instance: { getName: () => 'menu1' },
16 | }),
17 | },
18 | menuRegistry: {
19 | setOptionsCustomStyles: jest.fn(),
20 | },
21 | };
22 | }
23 |
24 | it('should render component', () => {
25 | const { output } = render(
26 |
27 |
28 |
29 |
30 | ,
31 | mockCtx()
32 | );
33 | expect(output.type).toEqual(View);
34 | const children = output.props.children;
35 | expect(children.length).toEqual(3);
36 | children.forEach(ch => {
37 | expect(ch.type).toBe(MenuOption);
38 | });
39 | });
40 |
41 | it('should accept optional (null) options', () => {
42 | const option = false;
43 | const { output } = render(
44 |
45 |
46 | {option ? : null}
47 |
48 | ,
49 | mockCtx()
50 | );
51 | expect(output.type).toEqual(View);
52 | const children = output.props.children;
53 | expect(children.length).toEqual(3);
54 | });
55 |
56 | it('should work with user defined options', () => {
57 | const UserOption = (props) => ;
58 | const { output } = render(
59 |
60 |
61 | ,
62 | mockCtx()
63 | );
64 | expect(output.type).toEqual(View);
65 | const children = output.props.children;
66 | expect(children.type).toBe(UserOption);
67 | });
68 |
69 | it('should register custom styles', () => {
70 | const customStyles = {
71 | optionsWrapper: { backgroundColor: 'red' },
72 | optionText: { color: 'blue' },
73 | };
74 | const customStyles2 = {
75 | optionsWrapper: { backgroundColor: 'blue' },
76 | };
77 | const ctx = mockCtx();
78 | const { instance, renderer } = render(
79 | ,
80 | ctx
81 | );
82 | instance.componentDidMount();
83 | expect(ctx.menuRegistry.setOptionsCustomStyles)
84 | .toHaveBeenLastCalledWith('menu1', customStyles)
85 | renderer.render()
86 | instance.componentDidUpdate();
87 | expect(ctx.menuRegistry.setOptionsCustomStyles)
88 | .toHaveBeenLastCalledWith('menu1', customStyles2)
89 | });
90 |
91 | });
92 |
--------------------------------------------------------------------------------
/examples/app/demos/Demo.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { StyleSheet, Text, View, TouchableHighlight } from 'react-native';
3 | import Menu from 'react-native-popup-menu';
4 |
5 | import Example from './Example';
6 | import BasicExample from './BasicExample';
7 | import ControlledExample from './ControlledExample';
8 | import ExtensionExample from './ExtensionExample';
9 | import ModalExample from './ModalExample';
10 | import StylingExample from './StylingExample';
11 | import NonRootExample from './NonRootExample';
12 | import TouchableExample from './TouchableExample';
13 | import MenuMethodsExample from './MenuMethodsExample';
14 | import CloseOnBackExample from './CloseOnBackExample';
15 | import FlatListExample from './FlatListExample';
16 | import InFlatListExample from './InFlatListExample';
17 | import PopoverExample from './PopoverExample';
18 |
19 | const demos = [
20 | { Component: BasicExample, name: 'Basic example' },
21 | { Component: Example, name: 'Advanced example' },
22 | { Component: ControlledExample, name: 'Controlled example' },
23 | { Component: MenuMethodsExample, name: 'Controlling menu using menu methods' },
24 | { Component: ExtensionExample, name: 'Extensions example' },
25 | { Component: ModalExample, name: 'Modal example' },
26 | { Component: StylingExample, name: 'Styling example' },
27 | { Component: TouchableExample, name: 'Touchable config example' },
28 | { Component: NonRootExample, name: 'Non root example' },
29 | { Component: CloseOnBackExample, name: 'Close on back button press example' },
30 | { Component: FlatListExample, name: 'Using FlatList' },
31 | { Component: InFlatListExample, name: 'Menu in FlatList' },
32 | { Component: PopoverExample, name: 'Popover renderer' },
33 | ];
34 |
35 | // show debug messages for demos.
36 | Menu.debug = true;
37 |
38 | export default class Demo extends Component {
39 | constructor(props, ctx) {
40 | super(props, ctx);
41 | this.state = {
42 | selected: undefined,
43 | };
44 | }
45 | render() {
46 | if (this.state.selected) {
47 | return ;
48 | }
49 | return (
50 |
51 |
52 | Select example:
53 | {demos.map(this.renderDemo, this)}
54 |
55 |
56 | );
57 | }
58 |
59 | renderDemo(demo, idx) {
60 | const type = idx + '. ' + demo.name;
61 | return (
62 | this.setState({selected: demo.Component})}>
63 |
64 | {type}
65 |
66 |
67 | );
68 | }
69 | }
70 |
71 | const styles = StyleSheet.create({
72 | container: {
73 | flex: 1,
74 | flexDirection:'column',
75 | alignItems:'center',
76 | justifyContent:'center',
77 | },
78 | });
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-native-popup-menu
2 | [](https://www.npmjs.com/package/react-native-popup-menu)
3 |
4 | Extensible popup menu component for React Native for Android, iOS and (unofficially) UWP and react-native-web.
5 |
6 | Features:
7 | * Simple to use popup/context menu
8 | * Multiple modes: animated, not animated, slide in from bottom or popover
9 | * By default opening and closing animations
10 | * Optional back button handling
11 | * Easy styling
12 | * Customizable on various levels - menu options, positioning, animations
13 | * Can work as controlled as well as uncontrolled component
14 | * Different lifecycle hooks
15 | * RTL layout support
16 |
17 | Community driven features:
18 | * Support for UWP, react-native-web and react-native-desktop
19 | * Typescript definitions
20 |
21 | We thank our community for maintaining features that goes over our scope.
22 |
23 | | Context Menu, Slide-in Menu | Popover |
24 | |---|---|
25 | |||
26 |
27 | ## Installation
28 |
29 | ```
30 | npm install react-native-popup-menu --save
31 | ```
32 | If you are using **older RN versions** check our compatibility table.
33 |
34 | ## Basic Usage
35 | Wrap your application inside `MenuProvider` and then simply use `Menu` component where you need it. Below you can find a simple example.
36 |
37 | For more detailed documentation check [API](./doc/api.md).
38 |
39 | ```js
40 | // your entry point
41 | import { MenuProvider } from 'react-native-popup-menu';
42 |
43 | export const App = () => (
44 |
45 |
46 |
47 | );
48 |
49 | // somewhere in your app
50 | import {
51 | Menu,
52 | MenuOptions,
53 | MenuOption,
54 | MenuTrigger,
55 | } from 'react-native-popup-menu';
56 |
57 | export const YourComponent = () => (
58 |
59 | Hello world!
60 |
70 |
71 | );
72 |
73 | ```
74 |
75 | ## Documentation
76 |
77 | - [Examples](doc/examples.md)
78 | - [API](doc/api.md)
79 | - [Extension points](doc/extensions.md)
80 |
81 | ## Contributing
82 | Contributions are welcome! Just open an issues with any idea or pull-request if it is no-brainer. Make sure all tests and linting rules pass.
83 |
84 | ## React Native Compatibility
85 | We keep compatibility on best effort basis.
86 |
87 | | popup-menu version | min RN (React) version |
88 | | ------------------ | -------------- |
89 | | 0.13 | 0.55 (16.3.1) |
90 | | 0.9 | 0.40 |
91 | | 0.8 | 0.38 |
92 | | 0.7 | 0.18 |
93 |
--------------------------------------------------------------------------------
/src/MenuOption.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { View, StyleSheet, Text } from 'react-native';
4 | import { debug } from './logger';
5 | import { makeTouchable } from './helpers';
6 | import { withCtx } from './MenuProvider';
7 |
8 | const noop = () => {};
9 |
10 | export class MenuOption extends Component {
11 |
12 | _onSelect() {
13 | const { value } = this.props;
14 | const onSelect = this.props.onSelect || this._getMenusOnSelect()
15 | const shouldClose = onSelect(value) !== false;
16 | debug('select option', value, shouldClose);
17 | if (shouldClose) {
18 | this.props.ctx.menuActions.closeMenu();
19 | }
20 | }
21 |
22 | _getMenusOnSelect() {
23 | const menu = this.props.ctx.menuActions._getOpenedMenu();
24 | return menu.instance.props.onSelect || noop;
25 | }
26 |
27 | _getCustomStyles() {
28 | // FIXME react 16.3 workaround for ControlledExample!
29 | const menu = this.props.ctx.menuActions._getOpenedMenu() || {}
30 | const { optionsCustomStyles } = menu;
31 | return {
32 | ...optionsCustomStyles,
33 | ...this.props.customStyles,
34 | }
35 | }
36 |
37 | render() {
38 | const { text, disabled, disableTouchable, children, style, testID, ...accessibilityProps } = this.props;
39 | const customStyles = this._getCustomStyles()
40 | if (text && React.Children.count(children) > 0) {
41 | console.warn("MenuOption: Please don't use text property together with explicit children. Children are ignored.");
42 | }
43 | if (disabled) {
44 | const disabledStyles = [defaultStyles.optionTextDisabled, customStyles.optionText];
45 | return (
46 |
47 | {text ? {text} : children}
48 |
49 | );
50 | }
51 | const rendered = (
52 |
53 | {text ? {text} : children}
54 |
55 | );
56 | if (disableTouchable) {
57 | return rendered;
58 | }
59 | else {
60 | const { Touchable, defaultTouchableProps } = makeTouchable(customStyles.OptionTouchableComponent);
61 | return (
62 | this._onSelect()}
65 | {...defaultTouchableProps}
66 | {...accessibilityProps}
67 | {...customStyles.optionTouchable}
68 | >
69 | {rendered}
70 |
71 | );
72 | }
73 | }
74 | }
75 |
76 | MenuOption.propTypes = {
77 | disabled: PropTypes.bool,
78 | disableTouchable: PropTypes.bool,
79 | onSelect: PropTypes.func,
80 | text: PropTypes.string,
81 | value: PropTypes.any,
82 | customStyles: PropTypes.object,
83 | testID: PropTypes.string,
84 | accessible: PropTypes.bool,
85 | accessibilityRole: PropTypes.string,
86 | accessibilityLabel: PropTypes.string,
87 | };
88 |
89 | const defaultStyles = StyleSheet.create({
90 | option: {
91 | padding: 5,
92 | backgroundColor: 'transparent',
93 | },
94 | optionTextDisabled: {
95 | color: '#ccc',
96 | },
97 | });
98 |
99 | export default withCtx(MenuOption);
100 |
--------------------------------------------------------------------------------
/examples/app/demos/TouchableExample.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | TouchableOpacity,
4 | TouchableHighlight,
5 | TouchableWithoutFeedback,
6 | Button,
7 | } from 'react-native';
8 | import Menu, {
9 | MenuProvider,
10 | MenuOptions,
11 | MenuOption,
12 | MenuTrigger,
13 | } from 'react-native-popup-menu';
14 |
15 | class TouchableExample extends React.Component {
16 | constructor(props) {
17 | super(props);
18 | this.state = {
19 | Touchable: Button,
20 | };
21 | }
22 |
23 | render() {
24 | const { Touchable } = this.state;
25 | const buttonText = 'Select ' + (Touchable ? (getDisplayName(Touchable)) : 'default');
26 | return (
27 |
28 |
29 |
63 |
64 |
82 |
83 |
84 | );
85 |
86 | }
87 | }
88 |
89 | const touchableOpacityProps = {
90 | activeOpacity: 0.6,
91 | };
92 |
93 | const touchableHighlightProps = {
94 | activeOpacity: 0.5,
95 | underlayColor: 'green',
96 | };
97 |
98 | const getDisplayName = Component => (
99 | Component.displayName ||
100 | Component.name ||
101 | (typeof Component === 'string' ? Component : 'Component')
102 | );
103 |
104 | export default TouchableExample;
105 |
--------------------------------------------------------------------------------
/__tests__/MenuTrigger-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { TouchableHighlight, View, Text } from 'react-native';
3 | import { render, normalizeStyle, nthChild } from './helpers';
4 |
5 | jest.dontMock('../src/MenuTrigger');
6 | jest.dontMock('../src/helpers');
7 | const { MenuTrigger } = require('../src/MenuTrigger');
8 | const { createSpy, objectContaining } = jasmine;
9 |
10 | describe('MenuTrigger', () => {
11 |
12 | it('should render component', () => {
13 | const { output } = render(
14 |
15 | Trigger Button
16 |
17 | );
18 | expect(output.type).toEqual(View);
19 | expect(nthChild(output, 1).type).toEqual(TouchableHighlight);
20 | expect(nthChild(output, 2).type).toEqual(View);
21 | expect(nthChild(output, 3)).toEqual(
22 | Trigger Button
23 | );
24 | });
25 |
26 | it('should render component using text property', () => {
27 | const { output } = render(
28 |
29 | );
30 | expect(nthChild(output, 1).type).toEqual(TouchableHighlight);
31 | expect(nthChild(output, 2).type).toEqual(View);
32 | expect(nthChild(output, 3)).toEqual(
33 | Trigger text
34 | );
35 | });
36 |
37 | it('should be enabled by default', () => {
38 | const { instance } = render(
39 |
40 | );
41 | // react deprecated default props -> disabled = undefined
42 | expect(instance.props.disabled).toBe(undefined);
43 | });
44 |
45 | it('should trigger on ref event', () => {
46 | const onRefSpy = createSpy();
47 | const { output } = render(
48 |
49 | );
50 | expect(typeof output.ref).toEqual('function');
51 | output.ref();
52 | expect(onRefSpy).toHaveBeenCalled();
53 | expect(onRefSpy.calls.count()).toEqual(1);
54 | });
55 |
56 | it('should open menu', () => {
57 | const menuActions = { openMenu: createSpy() };
58 | const { output } = render(
59 |
60 | );
61 | nthChild(output, 1).props.onPress();
62 | expect(menuActions.openMenu).toHaveBeenCalledWith('menu1');
63 | expect(menuActions.openMenu.calls.count()).toEqual(1);
64 | });
65 |
66 | it('should not open menu when disabled', () => {
67 | const { output, instance } = render(
68 |
69 | );
70 | const menuActions = { openMenu: createSpy() };
71 | instance.props.ctx = { menuActions };
72 | nthChild(output, 1).props.onPress();
73 | expect(menuActions.openMenu).not.toHaveBeenCalled();
74 | });
75 |
76 | it('should render trigger with custom styles', () => {
77 | const customStyles = {
78 | triggerWrapper: { backgroundColor: 'red' },
79 | triggerText: { color: 'blue' },
80 | triggerTouchable: { underlayColor: 'green' },
81 | };
82 | const { output } = render(
83 |
84 | );
85 | const touchable = nthChild(output, 1);
86 | const view = nthChild(output, 2);
87 | const text = nthChild(output, 3);
88 | expect(normalizeStyle(touchable.props))
89 | .toEqual(objectContaining({ underlayColor: 'green' }));
90 | expect(normalizeStyle(view.props.style))
91 | .toEqual(objectContaining(customStyles.triggerWrapper));
92 | expect(normalizeStyle(text.props.style))
93 | .toEqual(objectContaining(customStyles.triggerText));
94 | });
95 |
96 | });
97 |
--------------------------------------------------------------------------------
/examples/app/demos/StylingExample.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Text, StyleSheet } from 'react-native';
3 | import Menu, {
4 | MenuProvider,
5 | MenuOptions,
6 | MenuOption,
7 | MenuTrigger,
8 | renderers,
9 | } from 'react-native-popup-menu';
10 |
11 | const { ContextMenu, SlideInMenu, Popover } = renderers;
12 |
13 | class BasicExampleComponent extends Component {
14 |
15 | constructor(props, ctx) {
16 | super(props, ctx);
17 | this.state = { renderer: ContextMenu };
18 | }
19 |
20 | render() {
21 | return (
22 |
42 | );
43 | }
44 |
45 | }
46 |
47 | const BasicExample = () => (
48 |
49 |
50 |
51 | )
52 |
53 | export default BasicExample
54 |
55 | const triggerStyles = {
56 | triggerText: {
57 | color: 'white',
58 | },
59 | triggerOuterWrapper: {
60 | backgroundColor: 'orange',
61 | padding: 5,
62 | flex: 1,
63 | },
64 | triggerWrapper: {
65 | backgroundColor: 'blue',
66 | alignItems: 'center',
67 | justifyContent: 'center',
68 | flex: 1,
69 | },
70 | triggerTouchable: {
71 | underlayColor: 'darkblue',
72 | activeOpacity: 70,
73 | style : {
74 | flex: 1,
75 | },
76 | },
77 | };
78 |
79 | const optionsStyles = {
80 | optionsContainer: {
81 | backgroundColor: 'green',
82 | padding: 5,
83 | },
84 | optionsWrapper: {
85 | backgroundColor: 'purple',
86 | },
87 | optionWrapper: {
88 | backgroundColor: 'yellow',
89 | margin: 5,
90 | },
91 | optionTouchable: {
92 | underlayColor: 'gold',
93 | activeOpacity: 70,
94 | },
95 | optionText: {
96 | color: 'brown',
97 | },
98 | };
99 |
100 | const optionStyles = {
101 | optionTouchable: {
102 | underlayColor: 'red',
103 | activeOpacity: 40,
104 | },
105 | optionWrapper: {
106 | backgroundColor: 'pink',
107 | margin: 5,
108 | },
109 | optionText: {
110 | color: 'black',
111 | },
112 | };
113 |
114 | const styles = StyleSheet.create({
115 | container: {
116 | flexDirection: 'column',
117 | padding: 30,
118 | },
119 | backdrop: {
120 | backgroundColor: 'red',
121 | opacity: 0.5,
122 | },
123 | anchorStyle: {
124 | backgroundColor: 'blue',
125 | },
126 | });
127 |
128 | const menuProviderStyles = {
129 | menuProviderWrapper: styles.container,
130 | backdrop: styles.backdrop,
131 | };
132 |
--------------------------------------------------------------------------------
/src/helpers.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Platform, TouchableHighlight, TouchableNativeFeedback } from 'react-native';
3 |
4 | /**
5 | * Promisifies measure's callback function and returns layout object.
6 | */
7 | export const measure = ref => new Promise((resolve) => {
8 | ref.measure((x, y, width, height, pageX, pageY) => {
9 | resolve({
10 | x: pageX, y: pageY,
11 | width, height,
12 | })
13 | });
14 | });
15 |
16 | /**
17 | * Create unique menu name across all menu instances.
18 | */
19 | export const makeName = (function() {
20 | let nextID = 1;
21 | return () => `menu-${nextID++}`;
22 | })();
23 |
24 | /**
25 | * Create touchable component based on passed parameter and platform.
26 | * It also returns default props for specific touchable types.
27 | */
28 | export function makeTouchable(TouchableComponent) {
29 | const Touchable = TouchableComponent || Platform.select({
30 | android: TouchableNativeFeedback,
31 | ios: TouchableHighlight,
32 | default: TouchableHighlight,
33 | });
34 | let defaultTouchableProps = {};
35 | if (Touchable === TouchableHighlight) {
36 | defaultTouchableProps = { underlayColor: 'rgba(0, 0, 0, 0.1)' };
37 | }
38 | return { Touchable, defaultTouchableProps };
39 | }
40 |
41 | function includes(arr, value) {
42 | return arr.indexOf(value) > -1;
43 | }
44 |
45 | /**
46 | Log object - prepares object for logging by stripping all "private" or excluding fields
47 | */
48 | export function lo(object, ...excluding) {
49 | const exc = Array.from(excluding);
50 | function isObject(obj) {
51 | return obj === Object(obj);
52 | }
53 | function withoutPrivate(obj) {
54 | if (!isObject(obj)) return obj;
55 | const res = {};
56 | for (var property in obj) {
57 | if (obj.hasOwnProperty(property)) {
58 | if (!property.startsWith('_') && !includes(exc, property)) {
59 | res[property] = withoutPrivate(obj[property]);
60 | }
61 | }
62 | }
63 | return res;
64 | }
65 | return withoutPrivate(object);
66 | }
67 |
68 | /**
69 | Converts iterator to array
70 | */
71 | export function iterator2array(it) {
72 | // workaround around https://github.com/instea/react-native-popup-menu/issues/41#issuecomment-340290127
73 | const arr = [];
74 | for (let next = it.next(); !next.done; next = it.next()) {
75 | arr.push(next.value);
76 | }
77 | return arr;
78 | }
79 |
80 | /** checks if component is class component */
81 | export function isClassComponent(component) {
82 | return component.prototype && !!component.prototype.render;
83 | }
84 |
85 | /**
86 | * Higher order component to deprecate usage of component.
87 | * message - deprecate warning message
88 | * methods - array of method names to be delegated to deprecated component
89 | */
90 | export function deprecatedComponent(message, methods = []) {
91 | return function deprecatedComponentHOC(Component) {
92 | return class DeprecatedComponent extends React.Component {
93 | constructor(...args) {
94 | super(...args);
95 | methods.forEach(name => {
96 | // delegate methods to the component
97 | this[name] = (...args) => this.ref && this.ref[name](...args)
98 | });
99 | }
100 |
101 | render() {
102 | return
103 | }
104 |
105 | onRef = ref => this.ref = ref;
106 |
107 | componentDidMount() {
108 | console.warn(message);
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/__tests__/menuRegistry-test.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | jest.dontMock('../src/menuRegistry');
4 | const makeMenuRegistry = require('../src/menuRegistry').default;
5 |
6 | describe('menuRegistry tests', () => {
7 |
8 | const menu1 = {
9 | getName : () => 'menu1',
10 | };
11 |
12 | const menu2 = {
13 | getName : () => 'menu2',
14 | };
15 |
16 | it('should export function', () => {
17 | expect(makeMenuRegistry).to.be.a('function');
18 | });
19 |
20 | it('should create an object', () => {
21 | expect(makeMenuRegistry(new Map())).to.be.an('object');
22 | });
23 |
24 | describe('getMenu', () => {
25 | it('should return menu', () => {
26 | const menus = new Map([
27 | ['menu1', {instance: menu1}],
28 | ]);
29 | const registry = makeMenuRegistry(menus);
30 | expect(registry.getMenu('menu1').instance).to.eql(menu1);
31 | });
32 | });
33 |
34 | describe('subscribe', () => {
35 | it('should subscribe menu', () => {
36 | const registry = makeMenuRegistry();
37 | registry.subscribe(menu1);
38 | expect(registry.getMenu('menu1')).to.eql({name: 'menu1', instance: menu1});
39 | });
40 | });
41 |
42 | describe('unsubscribe', () => {
43 | it('should unsubscribe menu', () => {
44 | const menus = new Map([
45 | ['menu1', {name:'menu1', instance: menu1}],
46 | ['menu2', {name:'menu2', instance: menu2}],
47 | ]);
48 | const registry = makeMenuRegistry(menus);
49 | registry.unsubscribe(menu1);
50 | expect(registry.getMenu('menu1')).to.be.undefined;
51 | expect(registry.getMenu('menu2')).to.eql({name:'menu2', instance: menu2});
52 | });
53 | });
54 |
55 | describe('updateLayoutInfo', () => {
56 |
57 | it('should update only optionsLayout', () => {
58 | const menus = new Map([['menu1', {
59 | name: 'menu1',
60 | instance: menu1,
61 | triggerLayout: 5,
62 | optionsLayout: 6,
63 | }]]);
64 | const registry = makeMenuRegistry(menus);
65 | registry.updateLayoutInfo('menu1', { optionsLayout: 7 });
66 | expect(registry.getMenu('menu1')).to.eql({
67 | name: 'menu1',
68 | instance: menu1,
69 | triggerLayout: 5,
70 | optionsLayout: 7,
71 | });
72 | });
73 |
74 | it('should update only triggerLayout', () => {
75 | const menus = new Map([['menu1', {
76 | name: 'menu1',
77 | instance: menu1,
78 | triggerLayout: 5,
79 | optionsLayout: 6,
80 | }]]);
81 | const registry = makeMenuRegistry(menus);
82 | registry.updateLayoutInfo('menu1', { triggerLayout: 7 });
83 | expect(registry.getMenu('menu1')).to.eql({
84 | name: 'menu1',
85 | instance: menu1,
86 | triggerLayout: 7,
87 | optionsLayout: 6,
88 | });
89 | });
90 |
91 | it('should invalidate triggerLayout', () => {
92 | const menus = new Map([['menu1', {
93 | name: 'menu1',
94 | instance: menu1,
95 | triggerLayout: 5,
96 | }]]);
97 | const registry = makeMenuRegistry(menus);
98 | registry.updateLayoutInfo('menu1', { triggerLayout: undefined });
99 | expect(registry.getMenu('menu1')).to.eql({
100 | name: 'menu1',
101 | instance: menu1,
102 | triggerLayout: undefined,
103 | });
104 | });
105 |
106 | });
107 |
108 | describe('getAll', () => {
109 | it('should return all registered menus with its associated data', () => {
110 | const menus = new Map([
111 | ['menu1', {name: 'menu1', instance: menu1}],
112 | ['menu2', {name: 'menu2', instance: menu2, triggerLayout: 5}],
113 | ]);
114 | const registry = makeMenuRegistry(menus);
115 | const allMenus = registry.getAll();
116 | expect(allMenus.length).to.eql(2);
117 | expect(allMenus).to.contain({name: 'menu1', instance: menu1});
118 | expect(allMenus).to.contain({name: 'menu2', instance: menu2, triggerLayout: 5});
119 | });
120 | });
121 |
122 | });
123 |
--------------------------------------------------------------------------------
/doc/extensions.md:
--------------------------------------------------------------------------------
1 | # Extension points
2 |
3 | ## MenuOption
4 | `MenuOption` component can render any children. However if you want to add icons to all your menu options you can reduce the boilerplate code by writing your own option component.
5 |
6 | Simplest example that adds checkmark symbol (unicode 2713).
7 | ```
8 | const CheckedOption = (props) => (
9 |
10 | )
11 | ```
12 |
13 | **Note:** `MenuOption` can be placed anywhere inside of `MenuOptions` container. For example it can be rendered using `FlatList`.
14 |
15 | ## MenuOptions
16 | `` components are not required to be direct children of ``. You can pass any children to `` component. For example if you want to wrap options with custom component and add some text above options:
17 |
18 | ```
19 | const menu = (props) => (
20 |
30 | );
31 | ```
32 |
33 | #### Using `renderOptionsContainer` prop (DEPRECATED)
34 | You can also control rendering of `` component by passing rendering function into `renderOptionsContainer` property. It takes `` component as argument and it have to return react component.
35 |
36 | ```
37 | const optionsRenderer = (options) => (
38 |
39 | Some text
40 | {options}
41 |
42 | );
43 | const menu = (props) => (
44 |
51 | );
52 | ```
53 | **Note:** It is highly recommended to use first approach to extend menu options. `renderOptionsContainer` property might be removed in the future versions of the library.
54 |
55 | ## Custom renderer
56 | It is possible to use different renderer to display menu. There are already few predefined renderers: e.g. `ContextMenu` and `SlideInMenu` (from the `renderers` module). To use it you need to pass it to the `` props or use `setDefaultRenderer` (see [API](api.md#static-functions)):
57 |
58 | ```
59 | import { ..., renderers } from 'react-native-popup-menu';
60 | const menu = (props) => (
61 |
64 | );
65 | ```
66 |
67 | Responsibility of the renderer is to determine menu position, perform animation and provide basic styles. Here is simple example how to render menu on [0, 0] coordinates:
68 |
69 | ```
70 | const CustomMenu = (props) => {
71 | const { style, children, layouts, ...other } = props;
72 | const position = { top: 0, left: 0 }
73 | return (
74 |
75 | {children}
76 |
77 | );
78 | };
79 | ```
80 |
81 | To compute your own menu position you can use `layouts` property which is an object with properties:
82 |
83 | * `triggerLayout` contains dimensions and position of `` component (width, height, x, y).
84 | * `optionsLayout` contains dimensions of `` component (width, height);
85 | * `windowLayout` contains dimensions and position of working area/window i.e. `` area (width, height, x, y);
86 |
87 | In order to handle asynchronous closing animations, renderer can implement `close()`method which is called before menu closes. `close` method has to return `Promise`.
88 |
89 | **Note:** It is important that you pass rest of the properties to the wrapping component. We internally pass `onLayout` handler to detect layout change and re-render component. Also it is recommended to re-use styles from props in order to be customizable (via `customStyles.optionsContainer` or `optionsContainerStyle` option). Although for now it might suffice to pass only `onLayout` in addition to other standard props, we highly recommend to pass any properties (as in example) in order to stay compatible with any further versions of the library.
90 |
--------------------------------------------------------------------------------
/src/renderers/ContextMenu.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { I18nManager, Animated, Easing, StyleSheet, PixelRatio } from 'react-native';
3 | import { OPEN_ANIM_DURATION, CLOSE_ANIM_DURATION, USE_NATIVE_DRIVER } from '../constants';
4 |
5 | const axisPosition = (oDim, wDim, tPos, tDim) => {
6 | // if options are bigger than window dimension, then render at 0
7 | if (oDim > wDim) {
8 | return 0;
9 | }
10 | // render at trigger position if possible
11 | if (tPos + oDim <= wDim) {
12 | return tPos;
13 | }
14 | // aligned to the trigger from the bottom (right)
15 | if (tPos + tDim - oDim >= 0) {
16 | return tPos + tDim - oDim;
17 | }
18 | // compute center position
19 | let pos = Math.round(tPos + (tDim / 2) - (oDim / 2));
20 | // check top boundary
21 | if (pos < 0) {
22 | return 0;
23 | }
24 | // check bottom boundary
25 | if (pos + oDim > wDim) {
26 | return wDim - oDim;
27 | }
28 | // if everything ok, render in center position
29 | return pos;
30 | };
31 |
32 | function fit(pos, len, minPos, maxPos) {
33 | if (pos === undefined) {
34 | return undefined;
35 | }
36 | if (pos + len > maxPos) {
37 | pos = maxPos - len;
38 | }
39 | if (pos < minPos) {
40 | pos = minPos;
41 | }
42 | return pos;
43 | }
44 | // fits options (position) into safeArea
45 | export const fitPositionIntoSafeArea = (position, layouts) => {
46 | const { windowLayout, safeAreaLayout, optionsLayout } = layouts;
47 | if (!safeAreaLayout) {
48 | return position;
49 | }
50 | const { x: saX, y: saY, height: saHeight, width: saWidth } = safeAreaLayout;
51 | const { height: oHeight, width: oWidth } = optionsLayout;
52 | const { width: wWidth } = windowLayout;
53 | let { top, left, right } = position;
54 | top = fit(top, oHeight, saY, saY + saHeight);
55 | left = fit(left, oWidth, saX, saX + saWidth)
56 | right = fit(right, oWidth, wWidth - saX - saWidth, saX)
57 | return { top, left, right };
58 | }
59 |
60 | export const computePosition = (layouts, isRTL) => {
61 | const { windowLayout, triggerLayout, optionsLayout } = layouts;
62 | const { x: wX, y: wY, width: wWidth, height: wHeight } = windowLayout;
63 | const { x: tX, y: tY, height: tHeight, width: tWidth } = triggerLayout;
64 | const { height: oHeight, width: oWidth } = optionsLayout;
65 | const top = axisPosition(oHeight, wHeight, tY - wY, tHeight);
66 | const left = axisPosition(oWidth, wWidth, tX - wX, tWidth);
67 | const start = isRTL ? 'right' : 'left';
68 | const position = { top, [start]: left };
69 | return fitPositionIntoSafeArea(position, layouts);
70 | };
71 |
72 | export default class ContextMenu extends React.Component {
73 |
74 | constructor(props) {
75 | super(props);
76 | this.state = {
77 | scaleAnim: new Animated.Value(0.1),
78 | };
79 | }
80 |
81 | componentDidMount() {
82 | Animated.timing(this.state.scaleAnim, {
83 | duration: OPEN_ANIM_DURATION,
84 | toValue: 1,
85 | easing: Easing.out(Easing.cubic),
86 | useNativeDriver: USE_NATIVE_DRIVER,
87 | }).start();
88 | }
89 |
90 | close() {
91 | return new Promise(resolve => {
92 | Animated.timing(this.state.scaleAnim, {
93 | duration: CLOSE_ANIM_DURATION,
94 | toValue: 0,
95 | easing: Easing.in(Easing.cubic),
96 | useNativeDriver: USE_NATIVE_DRIVER,
97 | }).start(resolve);
98 | });
99 | }
100 |
101 | render() {
102 | const { style, children, layouts, ...other } = this.props;
103 | const animation = {
104 | transform: [ { scale: this.state.scaleAnim } ],
105 | opacity: this.state.scaleAnim,
106 | };
107 | const position = computePosition(layouts, I18nManager.isRTL);
108 | return (
109 |
110 | {children}
111 |
112 | );
113 | }
114 |
115 | }
116 |
117 | // public exports
118 | ContextMenu.computePosition = computePosition;
119 | ContextMenu.fitPositionIntoSafeArea = fitPositionIntoSafeArea;
120 |
121 | export const styles = StyleSheet.create({
122 | options: {
123 | position: 'absolute',
124 | borderRadius: 2,
125 | backgroundColor: 'white',
126 | width: PixelRatio.roundToNearestPixel(200),
127 |
128 | // Shadow only works on iOS.
129 | shadowColor: 'black',
130 | shadowOpacity: 0.3,
131 | shadowOffset: { width: 3, height: 3 },
132 | shadowRadius: 4,
133 |
134 | // This will elevate the view on Android, causing shadow to be drawn.
135 | elevation: 5,
136 | },
137 | });
138 |
--------------------------------------------------------------------------------
/src/Menu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { View } from 'react-native';
4 | import MenuOptions from './MenuOptions';
5 | import MenuTrigger from './MenuTrigger';
6 | import { makeName } from './helpers';
7 | import { debug, CFG } from './logger';
8 | import { withCtx } from './MenuProvider';
9 | import { menuConfig } from './config';
10 |
11 | const isRegularComponent = c => c.type !== MenuOptions && c.type !== MenuTrigger;
12 | const isTrigger = c => c.type === MenuTrigger;
13 | const isMenuOptions = c => c.type === MenuOptions;
14 |
15 | export class Menu extends Component {
16 |
17 | constructor(props) {
18 | super(props);
19 | this._name = this.props.name || makeName();
20 | this._forceClose = false;
21 | const { ctx } = props;
22 | if(!(ctx && ctx.menuActions)) {
23 | throw new Error("Menu component must be ancestor of MenuProvider");
24 | }
25 | }
26 |
27 | componentDidMount() {
28 | if (!this._validateChildren()) {
29 | return;
30 | }
31 | debug('subscribing menu', this._name);
32 | this.props.ctx.menuRegistry.subscribe(this);
33 | this.props.ctx.menuActions._notify();
34 | }
35 |
36 | componentDidUpdate(prevProps) {
37 | if (this.props.name !== prevProps.name) {
38 | console.warn('Menu name cannot be changed');
39 | }
40 | // force update if menu is opened as its content might have changed
41 | const force = this.isOpen();
42 | debug('component did update', this._name, force);
43 | this.props.ctx.menuActions._notify(force);
44 | }
45 |
46 | componentWillUnmount() {
47 | debug('unsubscribing menu', this._name);
48 | if (this.isOpen()) {
49 | this._forceClose = true;
50 | this.props.ctx.menuActions._notify();
51 | }
52 | this.props.ctx.menuRegistry.unsubscribe(this);
53 | }
54 |
55 | open() {
56 | return this.props.ctx.menuActions.openMenu(this._name);
57 | }
58 |
59 | close() {
60 | return this.props.ctx.menuActions.closeMenu();
61 | }
62 |
63 | isOpen() {
64 | if (this._forceClose) {
65 | return false;
66 | }
67 | return this.props.hasOwnProperty('opened') ? this.props.opened : this._opened;
68 | }
69 |
70 | getName() {
71 | return this._name;
72 | }
73 |
74 | render() {
75 | const { style } = this.props;
76 | const children = this._reduceChildren();
77 | return (
78 |
79 | {children}
80 |
81 | );
82 | }
83 |
84 | _reduceChildren() {
85 | return React.Children.toArray(this.props.children).reduce((r, child) => {
86 | if (isTrigger(child)) {
87 | r.push(React.cloneElement(child, {
88 | key: null,
89 | menuName: this._name,
90 | onRef: (t => this._trigger = t),
91 | }));
92 | }
93 | if (isRegularComponent(child)) {
94 | r.push(child);
95 | }
96 | return r;
97 | }, []);
98 | }
99 |
100 | _getTrigger() {
101 | return this._trigger;
102 | }
103 |
104 | _getOptions() {
105 | return React.Children.toArray(this.props.children).find(isMenuOptions);
106 | }
107 |
108 | _getOpened() {
109 | return this._opened;
110 | }
111 |
112 | _setOpened(opened) {
113 | this._opened = opened;
114 | }
115 |
116 | _validateChildren() {
117 | const children = React.Children.toArray(this.props.children);
118 | const options = children.find(isMenuOptions);
119 | if (!options) {
120 | console.warn('Menu has to contain MenuOptions component');
121 | }
122 | const trigger = children.find(isTrigger);
123 | if (!trigger) {
124 | console.warn('Menu has to contain MenuTrigger component');
125 | }
126 | return options && trigger;
127 | }
128 |
129 | }
130 |
131 | Menu.propTypes = {
132 | name: PropTypes.string,
133 | renderer: PropTypes.func,
134 | rendererProps: PropTypes.object,
135 | onSelect: PropTypes.func,
136 | onOpen: PropTypes.func,
137 | onClose: PropTypes.func,
138 | opened: PropTypes.bool,
139 | onBackdropPress: PropTypes.func,
140 | };
141 |
142 |
143 | const MenuExternal = withCtx(Menu);
144 | Object.defineProperty(MenuExternal, 'debug',
145 | {
146 | get: function() { return CFG.debug },
147 | set: function(val) { CFG.debug = val },
148 | });
149 | MenuExternal.setDefaultRenderer = (renderer) => {
150 | menuConfig.defRenderer = renderer;
151 | }
152 | MenuExternal.setDefaultRendererProps = (rendererProps) => {
153 | menuConfig.defRendererProps = rendererProps;
154 | }
155 | export default MenuExternal;
156 |
--------------------------------------------------------------------------------
/__tests__/helpers-test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { expect } from 'chai';
3 | import { TouchableHighlight, TouchableNativeFeedback, Platform } from 'react-native';
4 | import { View, Text } from 'react-native';
5 | import { render, nthChild } from './helpers';
6 |
7 | jest.dontMock('../src/helpers');
8 | const {
9 | measure,
10 | makeName,
11 | makeTouchable,
12 | lo,
13 | isClassComponent,
14 | deprecatedComponent,
15 | } = require('../src/helpers');
16 |
17 | describe('helpers test', () => {
18 |
19 | describe('measure', () => {
20 |
21 | it('should be a function', () => {
22 | expect(measure).to.be.a('function');
23 | });
24 |
25 | it('should promisify measure callback', done => {
26 | const ref = {
27 | measure: callback => callback(0, 0, 100, 200, 50, 20),
28 | };
29 | measure(ref).then(layout => {
30 | expect(layout).to.be.an('object');
31 | expect(layout).to.eql({
32 | x: 50, y: 20, width: 100, height: 200,
33 | });
34 | done();
35 | }).catch((err = 'promise rejected') => done(err));
36 | });
37 |
38 | });
39 |
40 | describe('makeName', () => {
41 | it('should be a function', () => {
42 | expect(makeName).to.be.a('function');
43 | });
44 |
45 | it('should return unique names', () => {
46 | const name1 = makeName(),
47 | name2 = makeName();
48 | expect(name1).to.be.a('string');
49 | expect(name2).to.be.a('string');
50 | expect(name1).not.to.be.equal(name2);
51 | });
52 | });
53 |
54 | describe('makeTouchable', () => {
55 |
56 | it('should create TouchableNativeFeedback for android', () => {
57 | Platform.select.mockImplementationOnce(o => {
58 | return o.android;
59 | });
60 | const { Touchable, defaultTouchableProps } = makeTouchable();
61 | expect(Touchable).to.be.equal(TouchableNativeFeedback);
62 | expect(defaultTouchableProps).to.be.an('object');
63 | });
64 |
65 | it('should create TouchableHighlight for ios', () => {
66 | Platform.select.mockImplementationOnce(o => {
67 | return o.ios;
68 | });
69 | const { Touchable, defaultTouchableProps } = makeTouchable();
70 | expect(Touchable).to.be.equal(TouchableHighlight);
71 | expect(defaultTouchableProps).to.be.an('object');
72 | });
73 |
74 | it('should create TouchableHighlight for default', () => {
75 | Platform.select.mockImplementationOnce(o => {
76 | return o.default;
77 | });
78 | const { Touchable, defaultTouchableProps } = makeTouchable();
79 | expect(Touchable).to.be.equal(TouchableHighlight);
80 | expect(defaultTouchableProps).to.be.an('object');
81 | });
82 |
83 | it('should return passed component', () => {
84 | const MyTouchable = () => null;
85 | const { Touchable, defaultTouchableProps } = makeTouchable(MyTouchable);
86 | expect(Touchable).to.be.equal(MyTouchable);
87 | expect(defaultTouchableProps).to.be.an('object');
88 | });
89 |
90 | });
91 |
92 | describe('lo', () => {
93 |
94 | it('should return primitive unchanged', () => {
95 | const res = lo(3);
96 | expect(res).to.be.equal(3);
97 | });
98 |
99 | it('should return nexted object without private fields unchanged', () => {
100 | const input = { a: 'ahoj', b : { c : 3, d : { e : 'nested' }}};
101 | const res = lo(input);
102 | expect(res).to.be.deep.equal(input);
103 | });
104 |
105 | it('should strip private fields', () => {
106 | const res = lo({ a: { _b : "private", c: 3}});
107 | expect(res).to.be.deep.equal({ a: { c : 3}});
108 | });
109 |
110 | it('should strip excluded fields', () => {
111 | const res = lo({ a: { b : "exc", c: 3}}, "b");
112 | expect(res).to.be.deep.equal({ a: { c : 3}});
113 | });
114 |
115 | });
116 |
117 | describe('deprecatedComponent', () => {
118 | it('should render deprecated component', () => {
119 | const Deprecated = deprecatedComponent('some warning')(View);
120 | const someStyle = { backgroundColor: 'pink' };
121 | const { output } = render(
122 |
123 | Some text
124 |
125 | );
126 | expect(output.type).to.equal(View);
127 | expect(output.props.style).to.equal(someStyle);
128 | expect(nthChild(output, 1)).to.be.deep.equal(
129 | Some text
130 | )
131 | })
132 | })
133 |
134 | });
135 |
136 | describe('isClassComponent', () => {
137 | it('return true for React.Component', () => {
138 | class TestComponent extends React.Component {
139 | render() {
140 | return null;
141 | }
142 | }
143 | const result = isClassComponent(TestComponent);
144 | expect(result).to.equal(true);
145 | });
146 |
147 | it('return false for functional componets', () => {
148 | function FuncComponent() {
149 | return null;
150 | }
151 | const result = isClassComponent(FuncComponent);
152 | expect(result).to.equal(false);
153 | });
154 |
155 | it('return false for arrow functions', () => {
156 | const ArrowComponent = () => null;
157 | const result = isClassComponent(ArrowComponent);
158 | expect(result).to.equal(false);
159 | });
160 |
161 | });
162 |
--------------------------------------------------------------------------------
/doc/examples.md:
--------------------------------------------------------------------------------
1 | # Examples
2 | ## Basic menu
3 | [BasicExample](../examples/BasicExample.js):
4 | Most basic example showing menu in its uncontrolled form where menu automatically opens once user click the trigger or as a result of imperative API.
5 |
6 | 
7 | ```js
8 |
18 | ```
19 | Both `MenuTrigger` and `MenuOption` can have arbitrary children so you have full power over its styling.
20 | Attribute `text` is just simple shorthand for `Text` child as it is the most common case.
21 |
22 | ## Per option action
23 | Actions (`onSelect`) can be attached also directly to specific `MenuOption` if you don't share logic between different options.
24 | In fact you can combine both approaches and have some actions attached to a menu option and rest will be handled by "global" [`Menu`](./api.md#menu) `onSelect` prop.
25 |
26 | ```js
27 |
35 | ```
36 | Not all children of `MenuOptions` must be a menu options (e.g. use it as a menu separator/divider)
37 |
38 | ## Declarative menu
39 | [ControlledExample](../examples/ControlledExample.js):
40 | Menu can be controlled also declaratively via properties.
41 | ```js
42 |