├── .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 | alert(`Selected number: ${value}`)}> 15 | 16 | 17 | 18 | 19 | Two 20 | 21 | 22 | 23 | 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 | alert(`Selected number: ${value}`)}> 15 | 16 | 17 | 18 | 19 | Two 20 | 21 | 22 | 23 | 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 | Alert.alert(value)}> 22 | 23 | 24 | ( 27 | 28 | )} 29 | style={{ height: 200 }} 30 | /> 31 | 32 | 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 | 22 | 25 | 26 | 30 | 33 | 43 | Two 44 | 45 | 46 | 51 | 52 | 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 | Alert.alert(value)}> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 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 | this.onBackdropPress()} 40 | onSelect={value => this.onOptionSelect(value)}> 41 | this.onTriggerPress()} 43 | text='Select option'/> 44 | 45 | 46 | 47 | Two 48 | 49 | 50 | 51 | 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 | 22 | 23 | 24 | this.setState({ visible: true })} text='Open modal' /> 25 | 26 | 27 | this.setState({ visible: false })}> 28 | 29 | Modal window: 30 | alert(`Selected number: ${value}`)}> 31 | 32 | 33 | 34 | 35 | this.setState({ visible: false })} text='Close modal' /> 36 | 37 | 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 | this.onOptionSelect(value)} 42 | name="menu-1" ref={this.onRef}> 43 | 44 | 45 | 46 | 47 | 48 | 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 | 15 | 16 | {'\u263A'} 17 | 18 | 19 | Hello world! 20 | 21 | 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 | alert(`Selected number: ${value}`)} 54 | renderer={renderers.NotAnimatedContextMenu} 55 | > 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 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 |